pc: PC setup tracker at /trackers/pc on the hardware schema
All checks were successful
Deploy production / deploy (push) Successful in 1m4s
All checks were successful
Deploy production / deploy (push) Successful in 1m4s
- model the existing hardware schema in Drizzle (devices, components, new sensitivities) - idempotent reconciling migration: new columns, checks, setup_overview view, grants, meta.tables - seed: main PC labels/hostnames, planned AMD X3D + RTX 5090 build, stan-ovh (OVH RISE-S), other devices, sensitivity placeholders (no invented cm/360) - owner-only add/edit via modals; public view hides notes/order numbers - trackers dropdown entry + icon, seo/title/sitemap, owner write prefix
This commit is contained in:
191
src/lib/server/db/schema/hardware.ts
Normal file
191
src/lib/server/db/schema/hardware.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import {
|
||||
bigint,
|
||||
boolean,
|
||||
check,
|
||||
date,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
numeric,
|
||||
text,
|
||||
unique
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { hardwareSchema } from './schemas';
|
||||
import { timestamptz } from './helpers';
|
||||
|
||||
// PC setup tracker (/trackers/pc): every device Stan owns or plans (PCs, laptops, servers,
|
||||
// phones, tablets) and the parts/peripherals on them, plus game sensitivities (cm/360) so
|
||||
// every mouse feels the same everywhere. The tables predate Drizzle (hand-made in Oct 2026
|
||||
// from Stan's own list + Megekko/Alternate order mails), so ids are bigint identities.
|
||||
// Unknown stays null; nothing is estimated. Order/invoice numbers and notes are owner-only.
|
||||
|
||||
export const HARDWARE_DEVICE_KINDS = [
|
||||
'desktop',
|
||||
'laptop',
|
||||
'server',
|
||||
'vm',
|
||||
'phone',
|
||||
'tablet',
|
||||
'monitor',
|
||||
'other'
|
||||
] as const;
|
||||
export const HARDWARE_DEVICE_STATUSES = ['active', 'spare', 'planned', 'retired', 'sold'] as const;
|
||||
export const HARDWARE_CATEGORIES = [
|
||||
'case',
|
||||
'cpu',
|
||||
'motherboard',
|
||||
'ram',
|
||||
'cpu_cooler',
|
||||
'gpu',
|
||||
'psu',
|
||||
'storage',
|
||||
'fan',
|
||||
'lighting',
|
||||
'monitor',
|
||||
'mouse',
|
||||
'keyboard',
|
||||
'headset',
|
||||
'audio',
|
||||
'controller',
|
||||
'accessory',
|
||||
'other'
|
||||
] as const;
|
||||
export const HARDWARE_STATUSES = ['in_use', 'planned', 'spare', 'retired', 'sold'] as const;
|
||||
|
||||
const list = (values: readonly string[]) => sql.raw(values.map((v) => `'${v}'`).join(', '));
|
||||
|
||||
/** Structured specs; free-form per category, e.g. {"socket":"AM5","cores":8}. */
|
||||
export type HardwareSpecs = Record<string, string | number | boolean | null>;
|
||||
|
||||
export const hardwareDevices = hardwareSchema.table(
|
||||
'devices',
|
||||
{
|
||||
id: bigint('id', { mode: 'number' }).primaryKey().generatedAlwaysAsIdentity(),
|
||||
/** Unique hostname-style slug, e.g. stan-pc, stan-ovh. */
|
||||
name: text('name').notNull().unique(),
|
||||
/** Display name, e.g. "Main PC". */
|
||||
label: text('label'),
|
||||
/** Other hostnames/OSes this device runs as, e.g. {stan-arch, stan-win11}. */
|
||||
hostnames: text('hostnames').array().notNull().default([]),
|
||||
kind: text('kind').notNull(),
|
||||
status: text('status').notNull().default('active'),
|
||||
brand: text('brand'),
|
||||
model: text('model'),
|
||||
specs: jsonb('specs').$type<HardwareSpecs>().notNull().default({}),
|
||||
/** One-off purchase price, euro cents incl. VAT. */
|
||||
priceCents: integer('price_cents'),
|
||||
/** Recurring cost per month, euro cents (servers/VMs); see notes for VAT. */
|
||||
monthlyCents: integer('monthly_cents'),
|
||||
/** Budget for planned builds, euro cents. */
|
||||
budgetCents: integer('budget_cents'),
|
||||
shop: text('shop'),
|
||||
purchaseDate: date('purchase_date'),
|
||||
url: text('url'),
|
||||
imageUrl: text('image_url'),
|
||||
sortOrder: integer('sort_order').notNull().default(100),
|
||||
notes: text('notes'),
|
||||
createdAt: timestamptz('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamptz('updated_at')
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date())
|
||||
},
|
||||
() => [
|
||||
check('devices_kind_check', sql`kind in (${list(HARDWARE_DEVICE_KINDS)})`),
|
||||
check('devices_status_check', sql`status in (${list(HARDWARE_DEVICE_STATUSES)})`),
|
||||
check(
|
||||
'devices_cents_check',
|
||||
sql`(price_cents is null or price_cents >= 0) and (monthly_cents is null or monthly_cents >= 0) and (budget_cents is null or budget_cents >= 0)`
|
||||
)
|
||||
]
|
||||
);
|
||||
|
||||
export const hardwareComponents = hardwareSchema.table(
|
||||
'components',
|
||||
{
|
||||
id: bigint('id', { mode: 'number' }).primaryKey().generatedAlwaysAsIdentity(),
|
||||
deviceId: bigint('device_id', { mode: 'number' })
|
||||
.notNull()
|
||||
.references(() => hardwareDevices.id, { onDelete: 'cascade' }),
|
||||
category: text('category').notNull(),
|
||||
brand: text('brand'),
|
||||
model: text('model').notNull(),
|
||||
quantity: integer('quantity').notNull().default(1),
|
||||
specs: jsonb('specs').$type<HardwareSpecs>().notNull().default({}),
|
||||
/** Per unit, euro cents incl. VAT. */
|
||||
unitPriceCents: integer('unit_price_cents'),
|
||||
/** unit price × quantity, euro cents incl. VAT, excl. shipping. */
|
||||
totalPriceCents: integer('total_price_cents'),
|
||||
shop: text('shop'),
|
||||
orderNumber: text('order_number'),
|
||||
orderDate: date('order_date'),
|
||||
warrantyUntil: date('warranty_until'),
|
||||
invoiceNumber: text('invoice_number'),
|
||||
status: text('status').notNull().default('in_use'),
|
||||
/** false: the purchase is real but whether it is (still) in this setup is unconfirmed. */
|
||||
verified: boolean('verified').notNull().default(true),
|
||||
/** Mice: DPI set in the mouse software, and the real DPI measured with a ruler. */
|
||||
dpi: integer('dpi'),
|
||||
measuredDpi: integer('measured_dpi'),
|
||||
url: text('url'),
|
||||
imageUrl: text('image_url'),
|
||||
notes: text('notes'),
|
||||
/** user_list | gmail | user_statement | manual | plan. */
|
||||
source: text('source'),
|
||||
createdAt: timestamptz('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamptz('updated_at')
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date())
|
||||
},
|
||||
(t) => [
|
||||
check('components_category_check', sql`category in (${list(HARDWARE_CATEGORIES)})`),
|
||||
check('components_status_check', sql`status in (${list(HARDWARE_STATUSES)})`),
|
||||
check('components_quantity_check', sql`quantity > 0`),
|
||||
check('components_unit_price_cents_check', sql`unit_price_cents >= 0`),
|
||||
check('components_total_price_cents_check', sql`total_price_cents >= 0`),
|
||||
check(
|
||||
'components_dpi_check',
|
||||
sql`(dpi is null or dpi > 0) and (measured_dpi is null or measured_dpi > 0)`
|
||||
),
|
||||
unique('components_dedupe_uq').on(t.deviceId, t.model, t.orderNumber).nullsNotDistinct(),
|
||||
index('components_device_idx').on(t.deviceId),
|
||||
index('components_category_idx').on(t.category),
|
||||
index('components_specs_gin').using('gin', t.specs)
|
||||
]
|
||||
);
|
||||
|
||||
/**
|
||||
* One row per game: the physical sensitivity Stan wants (cm per 360° turn) and, where the
|
||||
* game's yaw is known (degrees per mouse count at sens 1, e.g. CS2 0.022), the in-game value
|
||||
* is computed per mouse from its measured DPI. Stored values are only what Stan entered.
|
||||
*/
|
||||
export const hardwareSensitivities = hardwareSchema.table(
|
||||
'sensitivities',
|
||||
{
|
||||
id: bigint('id', { mode: 'number' }).primaryKey().generatedAlwaysAsIdentity(),
|
||||
game: text('game').notNull().unique(),
|
||||
cmPer360: numeric('cm_per_360', { precision: 7, scale: 2, mode: 'number' }),
|
||||
/** Degrees per count at sensitivity 1 (CS2/Source: 0.022). */
|
||||
yaw: numeric('yaw', { precision: 10, scale: 6, mode: 'number' }),
|
||||
/** What is set in-game right now (as entered by Stan). */
|
||||
inGameSens: numeric('in_game_sens', { precision: 10, scale: 4, mode: 'number' }),
|
||||
/** Mouse this value was set with (a hardware.components row, category mouse). */
|
||||
mouseId: bigint('mouse_id', { mode: 'number' }).references(() => hardwareComponents.id, {
|
||||
onDelete: 'set null'
|
||||
}),
|
||||
fov: text('fov'),
|
||||
notes: text('notes'),
|
||||
sortOrder: integer('sort_order').notNull().default(100),
|
||||
createdAt: timestamptz('created_at').notNull().defaultNow(),
|
||||
updatedAt: timestamptz('updated_at')
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date())
|
||||
},
|
||||
() => [
|
||||
check('sensitivities_cm_check', sql`cm_per_360 is null or cm_per_360 > 0`),
|
||||
check('sensitivities_yaw_check', sql`yaw is null or yaw > 0`)
|
||||
]
|
||||
);
|
||||
@@ -19,3 +19,4 @@ export * from './groceries';
|
||||
export * from './home';
|
||||
export * from './cars';
|
||||
export * from './music';
|
||||
export * from './hardware';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { pgSchema } from 'drizzle-orm/pg-core';
|
||||
|
||||
// One Postgres schema per domain (see ~/dev/setup/db/PLAN.md). `lists`, `legacy`,
|
||||
// `energy` and `hardware` are deliberately not modelled here: lists are created at
|
||||
// runtime through meta.create_list(), and energy/hardware belong to other tools.
|
||||
// One Postgres schema per domain (see ~/dev/setup/db/PLAN.md). `lists`, `legacy` and
|
||||
// `energy` are deliberately not modelled here: lists are created at runtime through
|
||||
// meta.create_list(), and energy belongs to ned-collector.
|
||||
export const authSchema = pgSchema('auth');
|
||||
export const moneySchema = pgSchema('money');
|
||||
export const houseSchema = pgSchema('house');
|
||||
@@ -19,3 +19,4 @@ export const groceriesSchema = pgSchema('groceries');
|
||||
export const homeSchema = pgSchema('home');
|
||||
export const carsSchema = pgSchema('cars');
|
||||
export const musicSchema = pgSchema('music');
|
||||
export const hardwareSchema = pgSchema('hardware');
|
||||
|
||||
360
src/lib/server/hardware.ts
Normal file
360
src/lib/server/hardware.ts
Normal file
@@ -0,0 +1,360 @@
|
||||
// PC setup tracker (/trackers/pc): reads and owner-only writes on the hardware schema.
|
||||
// Public visitors get everything except component notes, order and invoice numbers.
|
||||
import { db } from '#lib/server/db/index.js';
|
||||
import {
|
||||
HARDWARE_CATEGORIES,
|
||||
HARDWARE_DEVICE_KINDS,
|
||||
HARDWARE_DEVICE_STATUSES,
|
||||
HARDWARE_STATUSES,
|
||||
hardwareComponents,
|
||||
hardwareDevices,
|
||||
hardwareSensitivities
|
||||
} from '#lib/server/db/schema/index.js';
|
||||
import { textToSpecs, type Device, type Game, type Part } from '#lib/pc/shared.js';
|
||||
import { asc, eq, sql } from 'drizzle-orm';
|
||||
import * as z from 'zod';
|
||||
|
||||
const ORDER = [...HARDWARE_CATEGORIES];
|
||||
|
||||
export async function loadSetup(owner: boolean) {
|
||||
const [devices, parts, games] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(hardwareDevices)
|
||||
.orderBy(asc(hardwareDevices.sortOrder), asc(hardwareDevices.name)),
|
||||
db
|
||||
.select()
|
||||
.from(hardwareComponents)
|
||||
.orderBy(
|
||||
sql`array_position(${sql.raw(`ARRAY[${ORDER.map((c) => `'${c}'`).join(',')}]`)}::text[], ${hardwareComponents.category})`,
|
||||
sql`${hardwareComponents.orderDate} nulls last`,
|
||||
asc(hardwareComponents.model)
|
||||
),
|
||||
db
|
||||
.select()
|
||||
.from(hardwareSensitivities)
|
||||
.orderBy(asc(hardwareSensitivities.sortOrder), asc(hardwareSensitivities.game))
|
||||
]);
|
||||
const byDevice = new Map<number, Part[]>();
|
||||
for (const p of parts) {
|
||||
const part: Part = {
|
||||
id: p.id,
|
||||
deviceId: p.deviceId,
|
||||
category: p.category,
|
||||
brand: p.brand,
|
||||
model: p.model,
|
||||
quantity: p.quantity,
|
||||
specs: p.specs ?? {},
|
||||
unitPriceCents: p.unitPriceCents,
|
||||
totalPriceCents: p.totalPriceCents,
|
||||
shop: p.shop,
|
||||
orderDate: p.orderDate,
|
||||
warrantyUntil: p.warrantyUntil,
|
||||
status: p.status,
|
||||
verified: p.verified,
|
||||
dpi: p.dpi,
|
||||
measuredDpi: p.measuredDpi,
|
||||
url: p.url,
|
||||
imageUrl: p.imageUrl,
|
||||
notes: owner ? p.notes : null,
|
||||
orderNumber: owner ? p.orderNumber : null,
|
||||
invoiceNumber: owner ? p.invoiceNumber : null,
|
||||
source: owner ? p.source : null
|
||||
};
|
||||
const list = byDevice.get(p.deviceId) ?? [];
|
||||
list.push(part);
|
||||
byDevice.set(p.deviceId, list);
|
||||
}
|
||||
const out: Device[] = devices.map((d) => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
label: d.label,
|
||||
hostnames: d.hostnames,
|
||||
kind: d.kind,
|
||||
status: d.status,
|
||||
brand: d.brand,
|
||||
model: d.model,
|
||||
specs: d.specs ?? {},
|
||||
priceCents: d.priceCents,
|
||||
monthlyCents: d.monthlyCents,
|
||||
budgetCents: d.budgetCents,
|
||||
shop: d.shop,
|
||||
purchaseDate: d.purchaseDate,
|
||||
url: d.url,
|
||||
imageUrl: d.imageUrl,
|
||||
sortOrder: d.sortOrder,
|
||||
notes: d.notes,
|
||||
parts: byDevice.get(d.id) ?? []
|
||||
}));
|
||||
const sens: Game[] = games.map((g) => ({
|
||||
id: g.id,
|
||||
game: g.game,
|
||||
cmPer360: g.cmPer360,
|
||||
yaw: g.yaw,
|
||||
inGameSens: g.inGameSens,
|
||||
mouseId: g.mouseId,
|
||||
fov: g.fov,
|
||||
notes: owner ? g.notes : null,
|
||||
sortOrder: g.sortOrder
|
||||
}));
|
||||
return { devices: out, games: sens };
|
||||
}
|
||||
|
||||
// ---- form parsing (owner writes) ----
|
||||
|
||||
const str = (max: number) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.max(max)
|
||||
.transform((v) => (v === '' ? null : v))
|
||||
.nullable()
|
||||
.default(null);
|
||||
const req = (max: number) => z.string().trim().min(1).max(max);
|
||||
const int = (min: number, max: number) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((v, ctx) => {
|
||||
if (v === '') return null;
|
||||
const n = Number(v);
|
||||
if (!Number.isInteger(n) || n < min || n > max) {
|
||||
ctx.addIssue({ code: 'custom', message: `must be a whole number ${min}-${max}` });
|
||||
return z.NEVER;
|
||||
}
|
||||
return n;
|
||||
})
|
||||
.nullable()
|
||||
.default(null);
|
||||
const num = (max: number) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((v, ctx) => {
|
||||
if (v === '') return null;
|
||||
const n = Number(v.replace(',', '.'));
|
||||
if (!Number.isFinite(n) || n <= 0 || n > max) {
|
||||
ctx.addIssue({ code: 'custom', message: 'must be a positive number' });
|
||||
return z.NEVER;
|
||||
}
|
||||
return n;
|
||||
})
|
||||
.nullable()
|
||||
.default(null);
|
||||
/** Euros as typed ("1299", "1.299,00", "61.74") to cents. */
|
||||
const cents = z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((v, ctx) => {
|
||||
if (v === '') return null;
|
||||
let s = v.replace(/[€\s]/g, '');
|
||||
if (/,\d{1,2}$/.test(s)) s = s.replace(/\./g, '').replace(',', '.');
|
||||
else s = s.replace(/,/g, '');
|
||||
const n = Number(s);
|
||||
if (!Number.isFinite(n) || n < 0 || n > 10_000_000) {
|
||||
ctx.addIssue({ code: 'custom', message: 'not a price' });
|
||||
return z.NEVER;
|
||||
}
|
||||
return Math.round(n * 100);
|
||||
})
|
||||
.nullable()
|
||||
.default(null);
|
||||
const day = z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((v, ctx) => {
|
||||
if (v === '') return null;
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(v)) {
|
||||
ctx.addIssue({ code: 'custom', message: 'use YYYY-MM-DD' });
|
||||
return z.NEVER;
|
||||
}
|
||||
return v;
|
||||
})
|
||||
.nullable()
|
||||
.default(null);
|
||||
const link = z
|
||||
.string()
|
||||
.trim()
|
||||
.max(1000)
|
||||
.transform((v, ctx) => {
|
||||
if (v === '') return null;
|
||||
if (!/^https?:\/\//i.test(v)) {
|
||||
ctx.addIssue({ code: 'custom', message: 'links start with http(s)://' });
|
||||
return z.NEVER;
|
||||
}
|
||||
return v;
|
||||
})
|
||||
.nullable()
|
||||
.default(null);
|
||||
const specs = z
|
||||
.string()
|
||||
.max(5000)
|
||||
.default('')
|
||||
.transform((v) => textToSpecs(v));
|
||||
const pick = <T extends readonly [string, ...string[]]>(values: T) => z.enum(values);
|
||||
|
||||
const partSchema = z.object({
|
||||
id: int(1, 2 ** 31),
|
||||
deviceId: z.coerce.number().int().positive(),
|
||||
category: pick(HARDWARE_CATEGORIES),
|
||||
brand: str(120),
|
||||
model: req(300),
|
||||
quantity: z.coerce.number().int().min(1).max(999).default(1),
|
||||
specs,
|
||||
unitPrice: cents,
|
||||
shop: str(120),
|
||||
orderDate: day,
|
||||
warrantyUntil: day,
|
||||
orderNumber: str(120),
|
||||
invoiceNumber: str(120),
|
||||
status: pick(HARDWARE_STATUSES),
|
||||
verified: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v === 'on' || v === 'true'),
|
||||
dpi: int(1, 100_000),
|
||||
measuredDpi: int(1, 100_000),
|
||||
url: link,
|
||||
imageUrl: link,
|
||||
notes: str(4000)
|
||||
});
|
||||
|
||||
const deviceSchema = z.object({
|
||||
id: int(1, 2 ** 31),
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.regex(/^[a-z0-9][a-z0-9-]{0,62}$/, 'lowercase letters, digits and dashes'),
|
||||
label: str(120),
|
||||
hostnames: z
|
||||
.string()
|
||||
.default('')
|
||||
.transform((v) =>
|
||||
v
|
||||
.split(/[\s,]+/)
|
||||
.map((h) => h.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
.slice(0, 20)
|
||||
),
|
||||
kind: pick(HARDWARE_DEVICE_KINDS),
|
||||
status: pick(HARDWARE_DEVICE_STATUSES),
|
||||
brand: str(120),
|
||||
model: str(200),
|
||||
specs,
|
||||
price: cents,
|
||||
monthly: cents,
|
||||
budget: cents,
|
||||
shop: str(120),
|
||||
purchaseDate: day,
|
||||
url: link,
|
||||
sortOrder: int(0, 10_000),
|
||||
notes: str(4000)
|
||||
});
|
||||
|
||||
const gameSchema = z.object({
|
||||
id: int(1, 2 ** 31),
|
||||
game: req(120),
|
||||
cmPer360: num(1000),
|
||||
yaw: num(100),
|
||||
inGameSens: num(100_000),
|
||||
mouseId: int(1, 2 ** 31),
|
||||
fov: str(60),
|
||||
notes: str(2000),
|
||||
sortOrder: int(0, 10_000)
|
||||
});
|
||||
|
||||
type Result = { ok: true } | { ok: false; message: string };
|
||||
const problem = (e: z.ZodError): Result => ({
|
||||
ok: false,
|
||||
message: e.issues.map((i) => `${i.path.join('.') || 'form'}: ${i.message}`).join('; ')
|
||||
});
|
||||
const fields = (form: FormData) =>
|
||||
Object.fromEntries([...form.entries()].map(([k, v]) => [k, typeof v === 'string' ? v : '']));
|
||||
|
||||
export async function savePart(form: FormData): Promise<Result> {
|
||||
const parsed = partSchema.safeParse(fields(form));
|
||||
if (!parsed.success) return problem(parsed.error);
|
||||
const { id, unitPrice, ...p } = parsed.data;
|
||||
const values = {
|
||||
...p,
|
||||
unitPriceCents: unitPrice,
|
||||
totalPriceCents: unitPrice === null ? null : unitPrice * p.quantity,
|
||||
source: undefined as string | undefined
|
||||
};
|
||||
try {
|
||||
if (id) {
|
||||
delete values.source;
|
||||
await db.update(hardwareComponents).set(values).where(eq(hardwareComponents.id, id));
|
||||
} else {
|
||||
await db.insert(hardwareComponents).values({ ...values, source: 'manual' });
|
||||
}
|
||||
} catch (e) {
|
||||
return { ok: false, message: dbMessage(e) };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function saveDevice(form: FormData): Promise<Result> {
|
||||
const parsed = deviceSchema.safeParse(fields(form));
|
||||
if (!parsed.success) return problem(parsed.error);
|
||||
const { id, price, monthly, budget, sortOrder, ...d } = parsed.data;
|
||||
const values = {
|
||||
...d,
|
||||
priceCents: price,
|
||||
monthlyCents: monthly,
|
||||
budgetCents: budget,
|
||||
sortOrder: sortOrder ?? 100
|
||||
};
|
||||
try {
|
||||
if (id) await db.update(hardwareDevices).set(values).where(eq(hardwareDevices.id, id));
|
||||
else await db.insert(hardwareDevices).values(values);
|
||||
} catch (e) {
|
||||
return { ok: false, message: dbMessage(e) };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function saveGame(form: FormData): Promise<Result> {
|
||||
const parsed = gameSchema.safeParse(fields(form));
|
||||
if (!parsed.success) return problem(parsed.error);
|
||||
const { id, sortOrder, ...g } = parsed.data;
|
||||
const values = { ...g, sortOrder: sortOrder ?? 100 };
|
||||
try {
|
||||
if (id)
|
||||
await db.update(hardwareSensitivities).set(values).where(eq(hardwareSensitivities.id, id));
|
||||
else await db.insert(hardwareSensitivities).values(values);
|
||||
} catch (e) {
|
||||
return { ok: false, message: dbMessage(e) };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const rowId = (form: FormData) => {
|
||||
const n = Number(form.get('id'));
|
||||
return Number.isInteger(n) && n > 0 ? n : null;
|
||||
};
|
||||
|
||||
export async function deleteRow(
|
||||
table: 'part' | 'device' | 'game',
|
||||
form: FormData
|
||||
): Promise<Result> {
|
||||
const id = rowId(form);
|
||||
if (!id) return { ok: false, message: 'Missing id' };
|
||||
if (table === 'part') await db.delete(hardwareComponents).where(eq(hardwareComponents.id, id));
|
||||
else if (table === 'game')
|
||||
await db.delete(hardwareSensitivities).where(eq(hardwareSensitivities.id, id));
|
||||
else await db.delete(hardwareDevices).where(eq(hardwareDevices.id, id));
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function dbMessage(e: unknown): string {
|
||||
const code =
|
||||
(e as { code?: string; cause?: { code?: string } })?.code ??
|
||||
(e as { cause?: { code?: string } })?.cause?.code;
|
||||
if (code === '23505') return 'That already exists (same name, or same device + model + order).';
|
||||
if (code === '23503') return 'Unknown device or mouse.';
|
||||
if (code === '23514') return 'A value is out of range.';
|
||||
console.error('hardware write failed', e);
|
||||
return 'Could not save';
|
||||
}
|
||||
@@ -41,6 +41,8 @@ describe('owner gate', () => {
|
||||
expect(isOwnerWritePath('/trackers/health/import')).toBe(true);
|
||||
expect(isOwnerWritePath('/trackers/cars/ford/mustang')).toBe(true);
|
||||
expect(isOwnerOnlyPath('/trackers/cars/ford/mustang')).toBe(false);
|
||||
expect(isOwnerWritePath('/trackers/pc')).toBe(true);
|
||||
expect(isOwnerOnlyPath('/trackers/pc')).toBe(false);
|
||||
expect(isOwnerWritePath('/trackers/bookshelf')).toBe(false);
|
||||
expect(isOwnerWritePath('/lists/books')).toBe(false);
|
||||
});
|
||||
|
||||
@@ -49,7 +49,8 @@ export const ownerWritePrefixes = [
|
||||
'/trackers/houses',
|
||||
'/trackers/books',
|
||||
'/trackers/cars',
|
||||
'/trackers/music'
|
||||
'/trackers/music',
|
||||
'/trackers/pc'
|
||||
] as const;
|
||||
|
||||
const normalise = (pathname: string) => pathname.toLowerCase().replace(/\/+$/, '');
|
||||
|
||||
Reference in New Issue
Block a user