import Database from 'better-sqlite3'; import { mkdirSync } from 'node:fs'; import { dirname } from 'node:path'; export type LabelKind = 'free_2h' | 'free_3h' | 'free_4h' | 'pay_immediate'; export const LABEL_KINDS: LabelKind[] = ['free_2h', 'free_3h', 'free_4h', 'pay_immediate']; export function isLabelKind(v: unknown): v is LabelKind { return typeof v === 'string' && (LABEL_KINDS as string[]).includes(v); } /** The five categories on the city's printed Downtown & Waterfront parking map. */ export type AreaKind = 'green_lot' | 'free_2h' | 'limit_3h' | 'limit_4h' | 'no_limit'; export const AREA_KINDS: AreaKind[] = ['green_lot', 'free_2h', 'limit_3h', 'limit_4h', 'no_limit']; export function isAreaKind(v: unknown): v is AreaKind { return typeof v === 'string' && (AREA_KINDS as string[]).includes(v); } /** * One coloured area from the city map: an on-street segment (LineString) or a * city lot (Polygon). These are city geography, unrelated to ParkSmarter zones — * nothing here ever reaches the IPS API. */ export interface ParkingArea { id: string; kind: AreaKind; name: string; label: string; legend: string; hours: number; color: string; shape: 'line' | 'polygon'; /** GeoJSON geometry (LineString or Polygon), lon/lat. */ geometry: unknown; } /** * A whole-overlay correction. The map was georeferenced by fitting it to OSM, which * is good to a few metres but not perfect; this lets the alignment be nudged from the * phone against a live GPS fix and persisted, with no app release. * * Offsets are ground metres (east/north); scale and rotation apply about the * overlay's own centroid. */ export interface OverlayAdjust { dxMeters: number; dyMeters: number; scale: number; rotationDeg: number; updatedAt: number; } export const IDENTITY_OVERLAY: OverlayAdjust = { dxMeters: 0, dyMeters: 0, scale: 1, rotationDeg: 0, updatedAt: 0, }; export interface ZoneLabel { zoneId: string; customerId: string | null; zoneName: string | null; kind: LabelKind; note: string | null; updatedAt: number; updatedBy: string | null; } interface Row { zone_id: string; customer_id: string | null; zone_name: string | null; kind: string; note: string | null; updated_at: number; updated_by: string | null; } const toLabel = (r: Row): ZoneLabel => ({ zoneId: r.zone_id, customerId: r.customer_id, zoneName: r.zone_name, kind: r.kind as LabelKind, note: r.note, updatedAt: r.updated_at, updatedBy: r.updated_by, }); /** SQLite-backed store for zone labels + a manual IP denylist. */ export class LabelDb { private db: Database.Database; constructor(path: string) { if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true }); this.db = new Database(path); this.db.pragma('journal_mode = WAL'); this.db.exec(` CREATE TABLE IF NOT EXISTS zone_labels ( zone_id TEXT PRIMARY KEY, customer_id TEXT, zone_name TEXT, kind TEXT NOT NULL, note TEXT, updated_at INTEGER NOT NULL, updated_by TEXT ); CREATE TABLE IF NOT EXISTS blocked_ips ( ip TEXT PRIMARY KEY, reason TEXT, created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS zones ( zone_id TEXT PRIMARY KEY, zone_name TEXT, lat REAL, long REAL, data TEXT NOT NULL, updated_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS parking_areas ( id TEXT PRIMARY KEY, kind TEXT NOT NULL, name TEXT NOT NULL, label TEXT NOT NULL, legend TEXT NOT NULL, hours REAL NOT NULL, color TEXT NOT NULL, shape TEXT NOT NULL, geometry TEXT NOT NULL, updated_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS map_overlay ( id TEXT PRIMARY KEY, dx_meters REAL NOT NULL, dy_meters REAL NOT NULL, scale REAL NOT NULL, rotation REAL NOT NULL, updated_at INTEGER NOT NULL ); `); } /* ------------------------------------------------ city parking-map areas */ allAreas(): ParkingArea[] { const rows = this.db .prepare('SELECT * FROM parking_areas ORDER BY id') .all() as Array>; return rows.map((r) => ({ id: r.id, kind: r.kind as AreaKind, name: r.name, label: r.label, legend: r.legend, hours: r.hours, color: r.color, shape: r.shape as 'line' | 'polygon', geometry: JSON.parse(r.geometry), })); } areaCount(): number { return (this.db.prepare('SELECT COUNT(*) AS n FROM parking_areas').get() as { n: number }).n; } /** Newest updated_at across areas — the app uses it to skip redundant refreshes. */ areasUpdatedAt(): number { const r = this.db.prepare('SELECT MAX(updated_at) AS t FROM parking_areas').get() as { t: number | null; }; return r.t ?? 0; } /** * Replace the whole area set in one transaction. The areas come from a single * source document, so a partial update would leave a map that matches neither * the old print nor the new one. */ replaceAreas(areas: ParkingArea[]): number { const now = Date.now(); const insert = this.db.prepare( `INSERT INTO parking_areas (id, kind, name, label, legend, hours, color, shape, geometry, updated_at) VALUES (@id, @kind, @name, @label, @legend, @hours, @color, @shape, @geometry, @updatedAt)`, ); this.db.transaction((items: ParkingArea[]) => { this.db.prepare('DELETE FROM parking_areas').run(); for (const a of items) { insert.run({ ...a, geometry: JSON.stringify(a.geometry), updatedAt: now }); } })(areas); return areas.length; } getOverlay(): OverlayAdjust { const r = this.db.prepare("SELECT * FROM map_overlay WHERE id = 'default'").get() as | Record | undefined; if (!r) return IDENTITY_OVERLAY; return { dxMeters: r.dx_meters, dyMeters: r.dy_meters, scale: r.scale, rotationDeg: r.rotation, updatedAt: r.updated_at, }; } setOverlay(o: Omit): OverlayAdjust { const updatedAt = Date.now(); this.db .prepare( `INSERT INTO map_overlay (id, dx_meters, dy_meters, scale, rotation, updated_at) VALUES ('default', ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET dx_meters = excluded.dx_meters, dy_meters = excluded.dy_meters, scale = excluded.scale, rotation = excluded.rotation, updated_at = excluded.updated_at`, ) .run(o.dxMeters, o.dyMeters, o.scale, o.rotationDeg, updatedAt); return { ...o, updatedAt }; } /** Upsert mirrored parking areas (full Zone JSON in `data`). Returns count. */ upsertZones( rows: Array<{ zoneId: string; zoneName: string | null; lat: number | null; long: number | null; data: string }>, ): number { const stmt = this.db.prepare( `INSERT INTO zones (zone_id, zone_name, lat, long, data, updated_at) VALUES (@zoneId, @zoneName, @lat, @long, @data, @updatedAt) ON CONFLICT(zone_id) DO UPDATE SET zone_name = excluded.zone_name, lat = excluded.lat, long = excluded.long, data = excluded.data, updated_at = excluded.updated_at`, ); const now = Date.now(); this.db.transaction((items: typeof rows) => { for (const it of items) stmt.run({ ...it, updatedAt: now }); })(rows); return rows.length; } /** All mirrored zones as their original Zone objects. */ allZones(): unknown[] { const rows = this.db.prepare('SELECT data FROM zones').all() as { data: string }[]; return rows.map((r) => JSON.parse(r.data)); } zoneCount(): number { return (this.db.prepare('SELECT COUNT(*) AS n FROM zones').get() as { n: number }).n; } all(): ZoneLabel[] { return (this.db.prepare('SELECT * FROM zone_labels ORDER BY zone_id').all() as Row[]).map(toLabel); } get(zoneId: string): ZoneLabel | undefined { const r = this.db.prepare('SELECT * FROM zone_labels WHERE zone_id = ?').get(zoneId) as Row | undefined; return r ? toLabel(r) : undefined; } upsert(label: ZoneLabel): void { this.db .prepare( `INSERT INTO zone_labels (zone_id, customer_id, zone_name, kind, note, updated_at, updated_by) VALUES (@zoneId, @customerId, @zoneName, @kind, @note, @updatedAt, @updatedBy) ON CONFLICT(zone_id) DO UPDATE SET customer_id = excluded.customer_id, zone_name = excluded.zone_name, kind = excluded.kind, note = excluded.note, updated_at = excluded.updated_at, updated_by = excluded.updated_by`, ) .run(label); } delete(zoneId: string): boolean { return this.db.prepare('DELETE FROM zone_labels WHERE zone_id = ?').run(zoneId).changes > 0; } isBlocked(ip: string): boolean { return !!this.db.prepare('SELECT 1 FROM blocked_ips WHERE ip = ?').get(ip); } block(ip: string, reason: string): void { this.db .prepare('INSERT OR REPLACE INTO blocked_ips (ip, reason, created_at) VALUES (?, ?, ?)') .run(ip, reason, Date.now()); } seedBlocked(ips: string[]): void { for (const ip of ips) this.block(ip, 'seed'); } close(): void { this.db.close(); } }