Phases 1-5 + rooms/floors, accounts, custom types, users, notifications

Data model
- Properties, rooms (+optional floors), assets (typed custom fields + Zod
  runtime validator + move history), documents (polymorphic scope)
- Projects -> work packages -> tasks -> subtasks
- Decision events (scoped to project/property/asset/work_package)
- Checklist templates + instances, maintenance schedules (time + usage) with
  auto-materialized checklists on event recording
- Wiki (global + per-project) with revisions + tsvector FTS
- Property accounts (utility/meter numbers by kind)
- Notifications table + per-user channel prefs

Infra
- RBAC guards (requireCompany / requireAdmin)
- Storage abstraction: LocalDiskStorage (HMAC signed URLs) + S3Storage
  behind the same interface, switchable via STORAGE_BACKEND
- CSV export for assets / maintenance / decisions
- QR labels: /api/qr SVG endpoint + printable /assets/[id]/label
- Notifications: in-app + SMTP (own server via nodemailer) + Matrix
  (Client-Server API, per-company room) with opt-in per user
- Company switcher + auto-select first company on login

UI
- Topbar: bell with unread count, theme toggle, name, Sign Out (flat)
- Sidebar: main nav + dedicated Admin section (Asset types, Users, Company)
- Nested-route tabs on property / project / asset detail pages
- Admin UIs for users (invite, role, reset pw, deactivate) and company
  settings (default currency, Matrix room id)
- Custom asset type creation + field-def editor with immutable key/type
  guard and auto-deprecate when removing a field still referenced

Graph
- graphify-out/ committed: GRAPH_REPORT.md, graph.html, graph.json
This commit is contained in:
2026-04-23 15:18:11 +07:00
parent ad155d6344
commit b59904fdae
387 changed files with 70371 additions and 82 deletions
+380
View File
@@ -0,0 +1,380 @@
import 'dotenv/config';
import { and, eq, isNull, sql } from 'drizzle-orm';
import { pool } from '../../src/lib/server/db/client';
import { db } from '../../src/lib/server/db/client';
import { assetTypes, assetFieldDefs } from '../../src/lib/server/db/schema/assets';
type FieldType =
| 'text'
| 'textarea'
| 'int'
| 'float'
| 'bool'
| 'date'
| 'ip'
| 'cidr'
| 'mac'
| 'enum'
| 'multi_enum'
| 'url'
| 'email'
| 'asset_ref';
interface SeedField {
key: string;
label: string;
type: FieldType;
required?: boolean;
enumValues?: string[];
unit?: string;
helpText?: string;
}
interface SeedType {
slug: string;
name: string;
icon?: string;
description?: string;
fields: SeedField[];
}
// Fields shared by anything mounted on a network.
const NETWORK_FIELDS: SeedField[] = [
{ key: 'mgmt_ip', label: 'Management IP', type: 'ip' },
{ key: 'subnet', label: 'Subnet (CIDR)', type: 'cidr' },
{ key: 'vlan', label: 'VLAN ID', type: 'int' },
{ key: 'mac', label: 'MAC address', type: 'mac' },
{ key: 'hostname', label: 'Hostname', type: 'text' }
];
const SYSTEM_ASSET_TYPES: SeedType[] = [
// --- Network gear ---
{
slug: 'switch',
name: 'Switch',
icon: 'network-switch',
description: 'Managed or unmanaged network switch.',
fields: [
...NETWORK_FIELDS,
{ key: 'port_count', label: 'Port count', type: 'int' },
{ key: 'poe', label: 'PoE', type: 'bool' },
{ key: 'uplink_port', label: 'Uplink port', type: 'text' }
]
},
{
slug: 'router',
name: 'Router',
icon: 'router',
fields: [
...NETWORK_FIELDS,
{ key: 'wan_ip', label: 'WAN IP', type: 'ip' },
{ key: 'wan_provider', label: 'WAN provider', type: 'text' }
]
},
{
slug: 'access_point',
name: 'Access point',
icon: 'wifi',
fields: [
...NETWORK_FIELDS,
{ key: 'ssid', label: 'SSID', type: 'text' },
{ key: 'band', label: 'Band', type: 'enum', enumValues: ['2.4', '5', '6', 'tri'] }
]
},
{
slug: 'firewall',
name: 'Firewall',
icon: 'shield',
fields: [...NETWORK_FIELDS, { key: 'firmware', label: 'Firmware', type: 'text' }]
},
{
slug: 'patch_panel',
name: 'Patch panel',
icon: 'panel',
fields: [
{ key: 'port_count', label: 'Port count', type: 'int' },
{ key: 'rack_position', label: 'Rack U position', type: 'text' }
]
},
// --- Compute ---
{
slug: 'server',
name: 'Server',
icon: 'server',
fields: [
...NETWORK_FIELDS,
{ key: 'os', label: 'OS', type: 'text' },
{ key: 'cpu', label: 'CPU', type: 'text' },
{ key: 'ram_gb', label: 'RAM', type: 'int', unit: 'GB' },
{ key: 'disk_tb', label: 'Disk', type: 'float', unit: 'TB' }
]
},
// --- HVAC ---
{
slug: 'ac_unit',
name: 'Air conditioner',
icon: 'snowflake',
fields: [
{ key: 'capacity_btu', label: 'Capacity', type: 'int', unit: 'BTU/h' },
{
key: 'system_kind',
label: 'System type',
type: 'enum',
enumValues: ['split', 'multi_split', 'cassette', 'central', 'window']
},
{ key: 'refrigerant', label: 'Refrigerant', type: 'text' },
{ key: 'indoor_unit_count', label: 'Indoor units', type: 'int' }
]
},
{
slug: 'air_filter',
name: 'Air filter',
icon: 'filter',
fields: [
{ key: 'size', label: 'Size', type: 'text', placeholder: '24x24x2 in' } as SeedField,
{
key: 'rating',
label: 'Rating',
type: 'enum',
enumValues: ['MERV-8', 'MERV-11', 'MERV-13', 'HEPA', 'ULPA']
},
{ key: 'replace_interval_days', label: 'Replace every', type: 'int', unit: 'days' }
]
},
// --- Life safety ---
{
slug: 'fire_alarm_panel',
name: 'Fire alarm panel',
icon: 'alarm',
fields: [
{ key: 'panel_model', label: 'Panel model', type: 'text' },
{ key: 'zone_count', label: 'Zones', type: 'int' },
{ key: 'last_inspection', label: 'Last inspection', type: 'date' }
]
},
{
slug: 'smoke_detector',
name: 'Smoke detector',
icon: 'smoke',
fields: [
{
key: 'sensor_type',
label: 'Sensor type',
type: 'enum',
enumValues: ['ionization', 'photoelectric', 'dual']
},
{
key: 'power',
label: 'Power',
type: 'enum',
enumValues: ['battery', 'hardwired', 'hardwired_with_backup']
}
]
},
// --- Sensors / metering ---
{
slug: 'env_sensor',
name: 'Environment sensor',
icon: 'sensor',
fields: [
...NETWORK_FIELDS,
{
key: 'metrics',
label: 'Metrics',
type: 'multi_enum',
enumValues: ['temperature', 'humidity', 'co2', 'pm25', 'pressure', 'lux', 'noise']
}
]
},
{
slug: 'camera',
name: 'Camera',
icon: 'camera',
fields: [
...NETWORK_FIELDS,
{ key: 'resolution', label: 'Resolution', type: 'text', placeholder: '4K' } as SeedField,
{
key: 'mount',
label: 'Mount',
type: 'enum',
enumValues: ['ceiling', 'wall', 'pole', 'desk']
},
{ key: 'has_ptz', label: 'PTZ', type: 'bool' }
]
},
// --- Power ---
{
slug: 'ups',
name: 'UPS',
icon: 'battery',
fields: [
{ key: 'capacity_va', label: 'Capacity', type: 'int', unit: 'VA' },
{ key: 'battery_install_date', label: 'Battery installed', type: 'date' }
]
},
{
slug: 'generator',
name: 'Generator',
icon: 'generator',
fields: [
{ key: 'capacity_kw', label: 'Capacity', type: 'float', unit: 'kW' },
{
key: 'fuel',
label: 'Fuel',
type: 'enum',
enumValues: ['diesel', 'gas', 'lpg']
},
{ key: 'tank_litres', label: 'Tank', type: 'int', unit: 'L' }
]
},
{
slug: 'solar_inverter',
name: 'Solar inverter',
icon: 'sun',
fields: [
...NETWORK_FIELDS,
{ key: 'capacity_kw', label: 'Capacity', type: 'float', unit: 'kW' },
{ key: 'panel_count', label: 'Panel count', type: 'int' }
]
},
// --- Plumbing / mechanical ---
{
slug: 'pump',
name: 'Pump',
icon: 'pump',
fields: [
{ key: 'flow_rate_lpm', label: 'Flow rate', type: 'float', unit: 'L/min' },
{ key: 'head_m', label: 'Head', type: 'float', unit: 'm' }
]
},
{
slug: 'valve',
name: 'Valve',
icon: 'valve',
fields: [
{
key: 'kind',
label: 'Type',
type: 'enum',
enumValues: ['ball', 'gate', 'check', 'butterfly', 'solenoid']
},
{ key: 'size_mm', label: 'Size', type: 'int', unit: 'mm' }
]
}
];
async function upsertSystemType(seed: SeedType): Promise<{ id: string; created: boolean }> {
// System rows have company_id IS NULL.
const [existing] = await db
.select()
.from(assetTypes)
.where(and(isNull(assetTypes.companyId), eq(assetTypes.slug, seed.slug)))
.limit(1);
if (existing) {
// Heal name/icon/description drift without bumping schema_version.
const drift =
existing.name !== seed.name ||
(existing.icon ?? null) !== (seed.icon ?? null) ||
(existing.description ?? null) !== (seed.description ?? null);
if (drift) {
await db
.update(assetTypes)
.set({
name: seed.name,
icon: seed.icon ?? null,
description: seed.description ?? null
})
.where(eq(assetTypes.id, existing.id));
}
return { id: existing.id, created: false };
}
const [created] = await db
.insert(assetTypes)
.values({
companyId: null,
name: seed.name,
slug: seed.slug,
icon: seed.icon ?? null,
description: seed.description ?? null
})
.returning({ id: assetTypes.id });
return { id: created.id, created: true };
}
async function syncFieldDefs(typeId: string, seedFields: SeedField[]): Promise<void> {
const existing = await db
.select()
.from(assetFieldDefs)
.where(eq(assetFieldDefs.assetTypeId, typeId));
const byKey = new Map(existing.map((d) => [d.key, d]));
for (let i = 0; i < seedFields.length; i++) {
const f = seedFields[i];
const want = {
label: f.label,
type: f.type,
required: f.required ?? false,
order: i,
enumValues: f.enumValues ?? null,
unit: f.unit ?? null,
placeholder: null as string | null,
helpText: f.helpText ?? null,
deprecatedAt: null as Date | null
};
const cur = byKey.get(f.key);
if (!cur) {
await db.insert(assetFieldDefs).values({
assetTypeId: typeId,
key: f.key,
...want
});
continue;
}
const drift =
cur.label !== want.label ||
cur.type !== want.type ||
cur.required !== want.required ||
cur.order !== want.order ||
JSON.stringify(cur.enumValues ?? null) !== JSON.stringify(want.enumValues) ||
(cur.unit ?? null) !== want.unit ||
(cur.helpText ?? null) !== want.helpText ||
cur.deprecatedAt !== null;
if (drift) {
await db
.update(assetFieldDefs)
.set(want)
.where(eq(assetFieldDefs.id, cur.id));
}
byKey.delete(f.key);
}
// Anything left in byKey is a field the seed no longer declares — soft-deprecate.
for (const orphan of byKey.values()) {
if (orphan.deprecatedAt === null) {
await db
.update(assetFieldDefs)
.set({ deprecatedAt: sql`now()` })
.where(eq(assetFieldDefs.id, orphan.id));
}
}
}
async function main(): Promise<void> {
console.log(`Seeding ${SYSTEM_ASSET_TYPES.length} system asset types…`);
let created = 0;
for (const seed of SYSTEM_ASSET_TYPES) {
const { id, created: wasCreated } = await upsertSystemType(seed);
await syncFieldDefs(id, seed.fields);
if (wasCreated) created++;
console.log(` ${wasCreated ? '+' : '·'} ${seed.slug}`);
}
console.log(`Done. ${created} new, ${SYSTEM_ASSET_TYPES.length - created} updated.`);
await pool.end();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});