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:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user