Files
website/src/lib/server/db/schema/hardware.ts
Stan Runge 085a30bec8
All checks were successful
Deploy production / deploy (push) Successful in 1m4s
pc: PC setup tracker at /trackers/pc on the hardware schema
- 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
2026-10-24 17:55:48 +00:00

192 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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`)
]
);