@gelabs/ovr 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/dist/auth-auth.js +1 -1
  2. package/dist/auth.js +1 -1
  3. package/dist/{chunk-MDTRBOPQ.js → chunk-2C3VCTYJ.js} +1 -1
  4. package/dist/chunk-3YKVH4Y7.js +126 -0
  5. package/dist/chunk-6YFZLXFP.js +84 -0
  6. package/dist/{chunk-3NZ2XUBO.js → chunk-AJ2RZTVX.js} +9 -2
  7. package/dist/chunk-BI4EGLPG.js +298 -0
  8. package/dist/{chunk-3KIDW4LT.js → chunk-BVI5XDDA.js} +1 -1
  9. package/dist/{chunk-BIQ2J75Y.js → chunk-GLIK5BHP.js} +2 -2
  10. package/dist/{chunk-5YYR37CF.js → chunk-HGWPA7FU.js} +119 -0
  11. package/dist/{chunk-JEYT63LE.js → chunk-IBZVIUNI.js} +1 -1
  12. package/dist/chunk-IS3THKTE.js +89 -0
  13. package/dist/{chunk-4SZXBT56.js → chunk-NT72CQAI.js} +2 -2
  14. package/dist/{chunk-E2D7QT6N.js → chunk-TJSNVTVB.js} +1 -1
  15. package/dist/{chunk-5Z2IAD5I.js → chunk-TLG4C2XI.js} +2 -2
  16. package/dist/chunk-V7VQVDWS.js +237 -0
  17. package/dist/{chunk-IF5UAVIE.js → chunk-YC7G2IOZ.js} +1 -1
  18. package/dist/chunk-YGYA7KEG.js +423 -0
  19. package/dist/{chunk-GDOCD7LT.js → chunk-ZUMEOZ22.js} +5 -5
  20. package/dist/core-i18n.d.ts +2 -2
  21. package/dist/core-i18n.js +1 -1
  22. package/dist/core.d.ts +61 -1
  23. package/dist/core.js +1 -1
  24. package/dist/data-mock-store.js +265 -9
  25. package/dist/data-prisma-store.js +254 -1
  26. package/dist/data-seed-runner.js +18 -15
  27. package/dist/data.d.ts +54 -3
  28. package/dist/generated/client/edge.js +29 -9
  29. package/dist/generated/client/index-browser.js +26 -6
  30. package/dist/generated/client/index.d.ts +3544 -552
  31. package/dist/generated/client/index.js +29 -9
  32. package/dist/generated/client/package.json +1 -1
  33. package/dist/generated/client/schema.prisma +47 -9
  34. package/dist/generated/client/wasm.js +29 -9
  35. package/dist/index.d.ts +2 -2
  36. package/dist/index.js +1 -1
  37. package/dist/offline.d.ts +55 -19
  38. package/dist/offline.js +2 -375
  39. package/dist/{types-CtBC5-TW.d.ts → types-BOgdk0Jw.d.ts} +119 -0
  40. package/dist/types.d.ts +94 -1
  41. package/dist/types.js +1 -1
  42. package/dist/ui-components-admin/accounts-manager.d.ts +52 -0
  43. package/dist/ui-components-admin/accounts-manager.js +471 -0
  44. package/dist/ui-components-admin/admin-nav.d.ts +15 -1
  45. package/dist/ui-components-admin/admin-nav.js +387 -58
  46. package/dist/ui-components-admin/issuance-form.js +96 -23
  47. package/dist/ui-components-admin/logs-viewer.d.ts +13 -0
  48. package/dist/ui-components-admin/logs-viewer.js +102 -0
  49. package/dist/ui-components-admin/notifications-list.d.ts +5 -0
  50. package/dist/ui-components-admin/notifications-list.js +70 -0
  51. package/dist/ui-components-admin/officers-manager.d.ts +27 -0
  52. package/dist/ui-components-admin/officers-manager.js +271 -0
  53. package/dist/ui-components-admin/roles-manager.d.ts +37 -0
  54. package/dist/ui-components-admin/roles-manager.js +406 -0
  55. package/dist/ui-components-admin/ticket-preview.js +7 -7
  56. package/dist/ui-components-admin/tickets-table.js +56 -33
  57. package/dist/ui-components-citizen/citizen-nav.js +2 -2
  58. package/dist/ui-components-citizen/payment-form.js +5 -5
  59. package/dist/ui-components-citizen/payment-qr-dialog.js +4 -4
  60. package/dist/ui-components-citizen/ticket-not-found.js +2 -2
  61. package/dist/ui-components-citizen/violation-history-table.js +3 -3
  62. package/dist/ui-components-shared/amount-summary.js +4 -4
  63. package/dist/ui-components-shared/money.js +3 -3
  64. package/dist/ui-components-shared/municipal-seal.js +3 -3
  65. package/dist/ui-components-shared/official-header.js +4 -4
  66. package/dist/ui-components-shared/site-header.js +4 -4
  67. package/dist/ui-components-shared/sonner.js +2 -2
  68. package/dist/ui-components-shared/theme-toggle.js +3 -3
  69. package/dist/ui-components-shared/ticket-receipt.js +15 -7
  70. package/dist/ui-components-shared/violations-table.js +4 -4
  71. package/dist/ui-components-ui/badge.d.ts +1 -1
  72. package/dist/ui-components-ui/button.d.ts +1 -1
  73. package/dist/ui-components-ui/dropdown-menu.js +2 -237
  74. package/dist/ui-components-ui/sheet.js +3 -126
  75. package/dist/ui-config.d.ts +1 -1
  76. package/dist/ui-config.js +2 -2
  77. package/dist/ui-server.d.ts +1 -1
  78. package/dist/ui-server.js +2 -2
  79. package/package.json +3 -3
  80. package/prisma/migrations/20260622000000_add_issued_by/migration.sql +2 -0
  81. package/prisma/migrations/20260622010000_add_super_admin_role/migration.sql +3 -0
  82. package/prisma/migrations/20260622020000_add_apprehending_enforcer/migration.sql +4 -0
  83. package/prisma/migrations/20260622030000_custom_roles/migration.sql +30 -0
  84. package/prisma/migrations/20260622040000_add_activity_log/migration.sql +18 -0
  85. package/prisma/schema.prisma +47 -9
  86. package/dist/chunk-B634JHKZ.js +0 -181
@@ -1,20 +1,33 @@
1
- import { round2, computeCharges, makePaymentRef, fullName, addDays, makeBillNo, makeOrderOfPaymentNo, makeOvrTicketNo, enrich } from './chunk-B634JHKZ.js';
1
+ import { SUPER_ADMIN_LOCKED_PERMISSIONS, ALL_PERMISSIONS } from './chunk-IS3THKTE.js';
2
+ import { round2, computeCharges, makePaymentRef, fullName, addDays, makeBillNo, makeOrderOfPaymentNo, makeOvrTicketNo, enrich } from './chunk-BI4EGLPG.js';
2
3
  import 'server-only';
3
4
  import { promises } from 'fs';
5
+ import { randomUUID } from 'crypto';
4
6
  import os from 'os';
5
7
  import path from 'path';
6
8
 
7
- var SEED_VERSION = 1;
9
+ var SEED_VERSION = 5;
8
10
  var norm = (s) => s.trim().toLowerCase();
11
+ function slugRole(label) {
12
+ return label.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
13
+ }
14
+ function normalizePerms(perms) {
15
+ const set = new Set(perms);
16
+ return ALL_PERMISSIONS.filter((p) => set.has(p));
17
+ }
9
18
  function createMockStore(rules, seed) {
10
- const { CATALOG, OFFICERS, SEED_TICKETS, SEED_NEXT_SEQ } = seed;
19
+ const { CATALOG, OFFICERS, SEED_TICKETS, SEED_NEXT_SEQ, USERS, ROLES } = seed;
11
20
  const STORE_PATH = process.env.EOVR_STORE_FILE || path.join(os.tmpdir(), "eovr-store.json");
12
21
  const enrichRec = (rec, asOf) => enrich(rec, asOf, rules.surchargeRatePerMonth);
13
22
  function seeded() {
14
23
  return {
15
24
  version: SEED_VERSION,
16
25
  counter: SEED_NEXT_SEQ,
17
- tickets: structuredClone(SEED_TICKETS)
26
+ tickets: structuredClone(SEED_TICKETS),
27
+ users: structuredClone(USERS),
28
+ roles: structuredClone(ROLES),
29
+ activities: [],
30
+ officers: structuredClone(OFFICERS)
18
31
  };
19
32
  }
20
33
  async function writeStore(data) {
@@ -24,7 +37,7 @@ function createMockStore(rules, seed) {
24
37
  try {
25
38
  const raw = await promises.readFile(STORE_PATH, "utf8");
26
39
  const data = JSON.parse(raw);
27
- if (!data || data.version !== SEED_VERSION || !Array.isArray(data.tickets) || typeof data.counter !== "number") {
40
+ if (!data || data.version !== SEED_VERSION || !Array.isArray(data.tickets) || !Array.isArray(data.users) || !Array.isArray(data.roles) || !Array.isArray(data.activities) || !Array.isArray(data.officers) || typeof data.counter !== "number") {
28
41
  const fresh = seeded();
29
42
  await writeStore(fresh);
30
43
  return fresh;
@@ -42,16 +55,75 @@ function createMockStore(rules, seed) {
42
55
  return structuredClone(items);
43
56
  },
44
57
  async listOfficers() {
45
- return structuredClone(OFFICERS);
58
+ const data = await readStore();
59
+ return structuredClone(data.officers);
46
60
  },
47
61
  async getOfficer(id) {
48
- return OFFICERS.find((o) => o.id === id) ?? null;
62
+ const data = await readStore();
63
+ return data.officers.find((o) => o.id === id) ?? null;
64
+ },
65
+ async createOfficer(input) {
66
+ const data = await readStore();
67
+ const name = input.name.trim();
68
+ if (!name) throw new Error("Officer name is required.");
69
+ const office = input.office.trim();
70
+ if (!office) throw new Error("Office is required.");
71
+ const id = `off-${slugRole(name)}`;
72
+ if (id === "off-") {
73
+ throw new Error("Officer name must include letters or numbers.");
74
+ }
75
+ if (data.officers.some((o) => o.id === id)) {
76
+ throw new Error(`An officer "${name}" already exists.`);
77
+ }
78
+ const officer = {
79
+ id,
80
+ name,
81
+ badgeNo: input.badgeNo?.trim() || void 0,
82
+ office
83
+ };
84
+ data.officers.push(officer);
85
+ await writeStore(data);
86
+ return structuredClone(officer);
87
+ },
88
+ async updateOfficer(id, patch) {
89
+ const data = await readStore();
90
+ const o = data.officers.find((x) => x.id === id);
91
+ if (!o) throw new Error("Officer not found.");
92
+ if (patch.name !== void 0) {
93
+ const name = patch.name.trim();
94
+ if (!name) throw new Error("Officer name is required.");
95
+ o.name = name;
96
+ }
97
+ if (patch.office !== void 0) {
98
+ const office = patch.office.trim();
99
+ if (!office) throw new Error("Office is required.");
100
+ o.office = office;
101
+ }
102
+ if (patch.badgeNo !== void 0) o.badgeNo = patch.badgeNo?.trim() || void 0;
103
+ await writeStore(data);
104
+ return structuredClone(o);
105
+ },
106
+ async deleteOfficer(id) {
107
+ const data = await readStore();
108
+ const o = data.officers.find((x) => x.id === id);
109
+ if (!o) throw new Error("Officer not found.");
110
+ const ticketCount = data.tickets.filter((t) => t.officer.id === id).length;
111
+ if (ticketCount > 0) {
112
+ throw new Error(
113
+ `Can't remove \u2014 this officer has ${ticketCount} issued ticket(s).`
114
+ );
115
+ }
116
+ if (data.users.some((u) => u.officerId === id)) {
117
+ throw new Error("Can't remove \u2014 this officer is linked to an account.");
118
+ }
119
+ data.officers = data.officers.filter((x) => x.id !== id);
120
+ await writeStore(data);
49
121
  },
50
122
  async createTicket(input) {
51
123
  const store2 = await readStore();
52
124
  const now = /* @__PURE__ */ new Date();
53
125
  const seq = store2.counter;
54
- const officer = OFFICERS.find((o) => o.id === input.officerId) ?? OFFICERS[0];
126
+ const officer = store2.officers.find((o) => o.id === input.officerId) ?? store2.officers[0];
55
127
  const violations = input.violations.map((v) => {
56
128
  const c = CATALOG.find((x) => x.code === v.catalogCode);
57
129
  if (!c) throw new Error(`Unknown violation code: ${v.catalogCode}`);
@@ -82,6 +154,9 @@ function createMockStore(rules, seed) {
82
154
  officer,
83
155
  violations,
84
156
  remarks: input.remarks?.trim() || void 0,
157
+ issuedBy: input.issuedBy?.trim() || void 0,
158
+ apprehendingEnforcerId: input.apprehendingEnforcerId || void 0,
159
+ apprehendingEnforcerName: input.apprehendingEnforcerName?.trim() || void 0,
85
160
  assessedAt,
86
161
  dueDate: addDays(now, rules.dueWindowDays).toISOString(),
87
162
  basicFinesTotal,
@@ -109,7 +184,7 @@ function createMockStore(rules, seed) {
109
184
  );
110
185
  if (existing) return enrichRec(existing, /* @__PURE__ */ new Date());
111
186
  const created = new Date(input.createdAt);
112
- const officer = OFFICERS.find((o) => o.id === input.officerId) ?? OFFICERS[0];
187
+ const officer = data.officers.find((o) => o.id === input.officerId) ?? data.officers[0];
113
188
  const violations = input.violations.map((v) => {
114
189
  const c = CATALOG.find((x) => x.code === v.catalogCode);
115
190
  if (!c) throw new Error(`Unknown violation code: ${v.catalogCode}`);
@@ -135,6 +210,9 @@ function createMockStore(rules, seed) {
135
210
  officer,
136
211
  violations,
137
212
  remarks: input.remarks?.trim() || void 0,
213
+ issuedBy: input.issuedBy?.trim() || void 0,
214
+ apprehendingEnforcerId: input.apprehendingEnforcerId || void 0,
215
+ apprehendingEnforcerName: input.apprehendingEnforcerName?.trim() || void 0,
138
216
  assessedAt: input.createdAt,
139
217
  dueDate: addDays(created, rules.dueWindowDays).toISOString(),
140
218
  basicFinesTotal,
@@ -220,6 +298,184 @@ function createMockStore(rules, seed) {
220
298
  }) === todayKey
221
299
  ).length
222
300
  };
301
+ },
302
+ // ── Accounts (GE-013 RBAC) ──────────────────────────────────────────────
303
+ // Mock mode has no real credential store (login is the demo cookie); these
304
+ // back the accounts UI so it's fully browsable without Postgres.
305
+ async listUsers() {
306
+ const data = await readStore();
307
+ return structuredClone(data.users).sort(
308
+ (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
309
+ );
310
+ },
311
+ async createUser(input) {
312
+ const data = await readStore();
313
+ const username = input.username.trim();
314
+ if (!username) throw new Error("Username is required.");
315
+ const role = data.roles.find((r) => r.name === input.role);
316
+ if (!role) throw new Error(`Unknown role: ${input.role}`);
317
+ if (data.users.some((u) => norm(u.username) === norm(username))) {
318
+ throw new Error(`Username "${username}" is already taken.`);
319
+ }
320
+ const officer = input.officerId ? data.officers.find((o) => o.id === input.officerId) : void 0;
321
+ const account = {
322
+ id: `usr-${randomUUID()}`,
323
+ username,
324
+ role: role.name,
325
+ roleLabel: role.label,
326
+ active: true,
327
+ officerId: input.officerId ?? null,
328
+ officerName: officer?.name,
329
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
330
+ };
331
+ data.users.unshift(account);
332
+ await writeStore(data);
333
+ return structuredClone(account);
334
+ },
335
+ async setUserActive(id, active) {
336
+ const data = await readStore();
337
+ const u = data.users.find((x) => x.id === id);
338
+ if (!u) throw new Error("Account not found.");
339
+ u.active = active;
340
+ await writeStore(data);
341
+ return structuredClone(u);
342
+ },
343
+ async setUserRole(id, role) {
344
+ const data = await readStore();
345
+ const u = data.users.find((x) => x.id === id);
346
+ if (!u) throw new Error("Account not found.");
347
+ const roleDef = data.roles.find((r) => r.name === role);
348
+ if (!roleDef) throw new Error(`Unknown role: ${role}`);
349
+ u.role = roleDef.name;
350
+ u.roleLabel = roleDef.label;
351
+ await writeStore(data);
352
+ return structuredClone(u);
353
+ },
354
+ async updateUser(id, patch) {
355
+ const data = await readStore();
356
+ const u = data.users.find((x) => x.id === id);
357
+ if (!u) throw new Error("Account not found.");
358
+ if (patch.username !== void 0) {
359
+ const username = patch.username.trim();
360
+ if (!username) throw new Error("Username is required.");
361
+ if (data.users.some((x) => x.id !== id && norm(x.username) === norm(username))) {
362
+ throw new Error(`Username "${username}" is already taken.`);
363
+ }
364
+ u.username = username;
365
+ }
366
+ if (patch.role !== void 0) {
367
+ const roleDef = data.roles.find((r) => r.name === patch.role);
368
+ if (!roleDef) throw new Error(`Unknown role: ${patch.role}`);
369
+ u.role = roleDef.name;
370
+ u.roleLabel = roleDef.label;
371
+ }
372
+ if (patch.officerId !== void 0) {
373
+ u.officerId = patch.officerId ?? null;
374
+ const officer = patch.officerId ? data.officers.find((o) => o.id === patch.officerId) : void 0;
375
+ u.officerName = officer?.name;
376
+ }
377
+ await writeStore(data);
378
+ return structuredClone(u);
379
+ },
380
+ async resetUserPassword() {
381
+ },
382
+ // ── Roles (GE-013 RBAC) ─────────────────────────────────────────────────
383
+ async listRoles() {
384
+ const data = await readStore();
385
+ return structuredClone(data.roles).sort(
386
+ (a, b) => a.isSystem === b.isSystem ? a.label.localeCompare(b.label) : a.isSystem ? -1 : 1
387
+ );
388
+ },
389
+ async getRolePermissions(name) {
390
+ const data = await readStore();
391
+ const r = data.roles.find((x) => x.name === name);
392
+ return r ? normalizePerms(r.permissions) : [];
393
+ },
394
+ async createRole(input) {
395
+ const data = await readStore();
396
+ const label = input.label.trim();
397
+ if (!label) throw new Error("Role name is required.");
398
+ const name = slugRole(label);
399
+ if (!name) throw new Error("Role name must include letters or numbers.");
400
+ if (data.roles.some((r) => r.name === name)) {
401
+ throw new Error(`A role named "${label}" already exists.`);
402
+ }
403
+ const role = {
404
+ name,
405
+ label,
406
+ isSystem: false,
407
+ permissions: normalizePerms(input.permissions)
408
+ };
409
+ data.roles.push(role);
410
+ await writeStore(data);
411
+ return structuredClone(role);
412
+ },
413
+ async updateRole(name, patch) {
414
+ const data = await readStore();
415
+ const role = data.roles.find((r) => r.name === name);
416
+ if (!role) throw new Error("Role not found.");
417
+ if (patch.label !== void 0 && !role.isSystem) {
418
+ const label = patch.label.trim();
419
+ if (!label) throw new Error("Role name is required.");
420
+ role.label = label;
421
+ }
422
+ if (patch.permissions !== void 0) {
423
+ let perms = normalizePerms(patch.permissions);
424
+ if (name === "SUPER_ADMIN") {
425
+ perms = normalizePerms([...perms, ...SUPER_ADMIN_LOCKED_PERMISSIONS]);
426
+ }
427
+ role.permissions = perms;
428
+ }
429
+ await writeStore(data);
430
+ return structuredClone(role);
431
+ },
432
+ async deleteRole(name) {
433
+ const data = await readStore();
434
+ const role = data.roles.find((r) => r.name === name);
435
+ if (!role) throw new Error("Role not found.");
436
+ if (role.isSystem) throw new Error("System roles can't be deleted.");
437
+ const inUse = data.users.filter((u) => u.role === name).length;
438
+ if (inUse > 0) {
439
+ throw new Error(
440
+ `Can't delete "${role.label}" \u2014 it's assigned to ${inUse} account(s).`
441
+ );
442
+ }
443
+ data.roles = data.roles.filter((r) => r.name !== name);
444
+ await writeStore(data);
445
+ },
446
+ // ── Activity log (GE-022) ───────────────────────────────────────────────
447
+ async logActivity(entry) {
448
+ const data = await readStore();
449
+ data.activities.push({
450
+ id: `act-${randomUUID()}`,
451
+ actorId: entry.actorId ?? null,
452
+ actorUsername: entry.actorUsername ?? null,
453
+ action: entry.action,
454
+ summary: entry.summary,
455
+ targetType: entry.targetType,
456
+ targetId: entry.targetId,
457
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
458
+ });
459
+ await writeStore(data);
460
+ },
461
+ async listActivity(filter) {
462
+ const data = await readStore();
463
+ let items = [...data.activities].sort(
464
+ (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
465
+ );
466
+ if (filter?.action) items = items.filter((a) => a.action === filter.action);
467
+ if (filter?.actorId)
468
+ items = items.filter((a) => a.actorId === filter.actorId);
469
+ if (filter?.fromISO) {
470
+ const lo = Date.parse(filter.fromISO);
471
+ items = items.filter((a) => Date.parse(a.createdAt) >= lo);
472
+ }
473
+ if (filter?.toISO) {
474
+ const hi = Date.parse(filter.toISO);
475
+ items = items.filter((a) => Date.parse(a.createdAt) <= hi);
476
+ }
477
+ const limit = Math.min(Math.max(filter?.limit ?? 200, 1), 1e3);
478
+ return structuredClone(items.slice(0, limit));
223
479
  }
224
480
  };
225
481
  return store;
@@ -1,13 +1,22 @@
1
1
  import { prisma } from './chunk-MKALJTAU.js';
2
- import { round2, computeCharges, makePaymentRef, fullName, addDays, makeBillNo, makeOrderOfPaymentNo, makeOvrTicketNo, enrich } from './chunk-B634JHKZ.js';
2
+ import { SUPER_ADMIN_LOCKED_PERMISSIONS, ALL_PERMISSIONS } from './chunk-IS3THKTE.js';
3
+ import { round2, computeCharges, makePaymentRef, fullName, addDays, makeBillNo, makeOrderOfPaymentNo, makeOvrTicketNo, enrich } from './chunk-BI4EGLPG.js';
3
4
  import 'server-only';
4
5
 
5
6
  var norm = (s) => s.trim().toLowerCase();
7
+ function slugRole(label) {
8
+ return label.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
9
+ }
10
+ function normalizePerms(perms) {
11
+ const set = new Set(perms);
12
+ return ALL_PERMISSIONS.filter((p) => set.has(p));
13
+ }
6
14
  var ticketInclude = {
7
15
  officer: true,
8
16
  violations: true,
9
17
  payment: true
10
18
  };
19
+ var userInclude = { officer: true, roleRef: true };
11
20
  function toOfficer(o) {
12
21
  return {
13
22
  id: o.id,
@@ -16,6 +25,26 @@ function toOfficer(o) {
16
25
  office: o.office
17
26
  };
18
27
  }
28
+ function toUserAccount(u) {
29
+ return {
30
+ id: u.id,
31
+ username: u.username,
32
+ role: u.role,
33
+ roleLabel: u.roleRef?.label,
34
+ active: u.active,
35
+ officerId: u.officerId ?? null,
36
+ officerName: u.officer?.name,
37
+ createdAt: u.createdAt.toISOString()
38
+ };
39
+ }
40
+ function toRoleDef(r) {
41
+ return {
42
+ name: r.name,
43
+ label: r.label,
44
+ isSystem: r.isSystem,
45
+ permissions: normalizePerms(r.permissions)
46
+ };
47
+ }
19
48
  function toRecord(row) {
20
49
  const violator = {
21
50
  firstName: row.violatorFirstName,
@@ -45,6 +74,9 @@ function toRecord(row) {
45
74
  officer: toOfficer(row.officer),
46
75
  violations,
47
76
  remarks: row.remarks ?? void 0,
77
+ issuedBy: row.issuedBy ?? void 0,
78
+ apprehendingEnforcerId: row.apprehendingEnforcerId ?? void 0,
79
+ apprehendingEnforcerName: row.apprehendingEnforcerName ?? void 0,
48
80
  assessedAt: row.assessedAt.toISOString(),
49
81
  dueDate: row.dueDate.toISOString(),
50
82
  basicFinesTotal: row.basicFinesTotal.toNumber(),
@@ -84,6 +116,49 @@ function createPrismaStore(rules) {
84
116
  const o = await prisma.officer.findUnique({ where: { id } });
85
117
  return o ? toOfficer(o) : null;
86
118
  },
119
+ async createOfficer(input) {
120
+ const name = input.name.trim();
121
+ if (!name) throw new Error("Officer name is required.");
122
+ const office = input.office.trim();
123
+ if (!office) throw new Error("Office is required.");
124
+ const id = `off-${slugRole(name)}`;
125
+ if (id === "off-") throw new Error("Officer name must include letters or numbers.");
126
+ const existing = await prisma.officer.findUnique({ where: { id } });
127
+ if (existing) throw new Error(`An officer "${name}" already exists.`);
128
+ const o = await prisma.officer.create({
129
+ data: { id, name, badgeNo: input.badgeNo?.trim() || null, office }
130
+ });
131
+ return toOfficer(o);
132
+ },
133
+ async updateOfficer(id, patch) {
134
+ const data = {};
135
+ if (patch.name !== void 0) {
136
+ const name = patch.name.trim();
137
+ if (!name) throw new Error("Officer name is required.");
138
+ data.name = name;
139
+ }
140
+ if (patch.office !== void 0) {
141
+ const office = patch.office.trim();
142
+ if (!office) throw new Error("Office is required.");
143
+ data.office = office;
144
+ }
145
+ if (patch.badgeNo !== void 0) data.badgeNo = patch.badgeNo?.trim() || null;
146
+ const o = await prisma.officer.update({ where: { id }, data });
147
+ return toOfficer(o);
148
+ },
149
+ async deleteOfficer(id) {
150
+ const tickets = await prisma.ticket.count({ where: { officerId: id } });
151
+ if (tickets > 0) {
152
+ throw new Error(
153
+ `Can't remove \u2014 this officer has ${tickets} issued ticket(s).`
154
+ );
155
+ }
156
+ const linked = await prisma.user.count({ where: { officerId: id } });
157
+ if (linked > 0) {
158
+ throw new Error("Can't remove \u2014 this officer is linked to an account.");
159
+ }
160
+ await prisma.officer.delete({ where: { id } });
161
+ },
87
162
  async createTicket(input) {
88
163
  const now = /* @__PURE__ */ new Date();
89
164
  const v = input.violator;
@@ -145,6 +220,9 @@ function createPrismaStore(rules) {
145
220
  apprehendedAt: new Date(input.apprehendedAt),
146
221
  placeOfViolation: input.placeOfViolation?.trim() || null,
147
222
  remarks: input.remarks?.trim() || null,
223
+ issuedBy: input.issuedBy?.trim() || null,
224
+ apprehendingEnforcerId: input.apprehendingEnforcerId || null,
225
+ apprehendingEnforcerName: input.apprehendingEnforcerName?.trim() || null,
148
226
  officerId: officer.id,
149
227
  assessedAt: now,
150
228
  dueDate: addDays(now, rules.dueWindowDays),
@@ -228,6 +306,9 @@ function createPrismaStore(rules) {
228
306
  apprehendedAt: new Date(input.apprehendedAt),
229
307
  placeOfViolation: input.placeOfViolation?.trim() || null,
230
308
  remarks: input.remarks?.trim() || null,
309
+ issuedBy: input.issuedBy?.trim() || null,
310
+ apprehendingEnforcerId: input.apprehendingEnforcerId || null,
311
+ apprehendingEnforcerName: input.apprehendingEnforcerName?.trim() || null,
231
312
  officerId: officer.id,
232
313
  assessedAt: created,
233
314
  dueDate: addDays(created, rules.dueWindowDays),
@@ -339,6 +420,178 @@ function createPrismaStore(rules) {
339
420
  }) === todayKey
340
421
  ).length
341
422
  };
423
+ },
424
+ // ── Accounts (GE-013 RBAC) ──────────────────────────────────────────────
425
+ async listUsers() {
426
+ const rows = await prisma.user.findMany({
427
+ include: userInclude,
428
+ orderBy: { createdAt: "desc" }
429
+ });
430
+ return rows.map(toUserAccount);
431
+ },
432
+ async createUser(input) {
433
+ const username = input.username.trim();
434
+ if (!username) throw new Error("Username is required.");
435
+ const role = await prisma.role.findUnique({ where: { name: input.role } });
436
+ if (!role) throw new Error(`Unknown role: ${input.role}`);
437
+ const existing = await prisma.user.findUnique({ where: { username } });
438
+ if (existing) throw new Error(`Username "${username}" is already taken.`);
439
+ const row = await prisma.user.create({
440
+ data: {
441
+ username,
442
+ passwordHash: input.passwordHash,
443
+ role: input.role,
444
+ active: true,
445
+ officerId: input.officerId ?? null
446
+ },
447
+ include: userInclude
448
+ });
449
+ return toUserAccount(row);
450
+ },
451
+ async setUserActive(id, active) {
452
+ const row = await prisma.user.update({
453
+ where: { id },
454
+ data: { active },
455
+ include: userInclude
456
+ });
457
+ return toUserAccount(row);
458
+ },
459
+ async setUserRole(id, role) {
460
+ const exists = await prisma.role.findUnique({ where: { name: role } });
461
+ if (!exists) throw new Error(`Unknown role: ${role}`);
462
+ const row = await prisma.user.update({
463
+ where: { id },
464
+ data: { role },
465
+ include: userInclude
466
+ });
467
+ return toUserAccount(row);
468
+ },
469
+ async updateUser(id, patch) {
470
+ const data = {};
471
+ if (patch.username !== void 0) {
472
+ const username = patch.username.trim();
473
+ if (!username) throw new Error("Username is required.");
474
+ const existing = await prisma.user.findUnique({ where: { username } });
475
+ if (existing && existing.id !== id) {
476
+ throw new Error(`Username "${username}" is already taken.`);
477
+ }
478
+ data.username = username;
479
+ }
480
+ if (patch.role !== void 0) {
481
+ const role = await prisma.role.findUnique({ where: { name: patch.role } });
482
+ if (!role) throw new Error(`Unknown role: ${patch.role}`);
483
+ data.roleRef = { connect: { name: patch.role } };
484
+ }
485
+ if (patch.officerId !== void 0) {
486
+ data.officer = patch.officerId ? { connect: { id: patch.officerId } } : { disconnect: true };
487
+ }
488
+ const row = await prisma.user.update({
489
+ where: { id },
490
+ data,
491
+ include: userInclude
492
+ });
493
+ return toUserAccount(row);
494
+ },
495
+ async resetUserPassword(id, passwordHash) {
496
+ await prisma.user.update({ where: { id }, data: { passwordHash } });
497
+ },
498
+ // ── Roles (GE-013 RBAC) ─────────────────────────────────────────────────
499
+ async listRoles() {
500
+ const rows = await prisma.role.findMany({
501
+ orderBy: [{ isSystem: "desc" }, { label: "asc" }]
502
+ });
503
+ return rows.map(toRoleDef);
504
+ },
505
+ async getRolePermissions(name) {
506
+ const r = await prisma.role.findUnique({ where: { name } });
507
+ return r ? normalizePerms(r.permissions) : [];
508
+ },
509
+ async createRole(input) {
510
+ const label = input.label.trim();
511
+ if (!label) throw new Error("Role name is required.");
512
+ const name = slugRole(label);
513
+ if (!name) throw new Error("Role name must include letters or numbers.");
514
+ const existing = await prisma.role.findUnique({ where: { name } });
515
+ if (existing) throw new Error(`A role named "${label}" already exists.`);
516
+ const row = await prisma.role.create({
517
+ data: {
518
+ name,
519
+ label,
520
+ isSystem: false,
521
+ permissions: normalizePerms(input.permissions)
522
+ }
523
+ });
524
+ return toRoleDef(row);
525
+ },
526
+ async updateRole(name, patch) {
527
+ const role = await prisma.role.findUnique({ where: { name } });
528
+ if (!role) throw new Error("Role not found.");
529
+ const data = {};
530
+ if (patch.label !== void 0 && !role.isSystem) {
531
+ const label = patch.label.trim();
532
+ if (!label) throw new Error("Role name is required.");
533
+ data.label = label;
534
+ }
535
+ if (patch.permissions !== void 0) {
536
+ let perms = normalizePerms(patch.permissions);
537
+ if (name === "SUPER_ADMIN") {
538
+ perms = normalizePerms([...perms, ...SUPER_ADMIN_LOCKED_PERMISSIONS]);
539
+ }
540
+ data.permissions = perms;
541
+ }
542
+ const row = await prisma.role.update({ where: { name }, data });
543
+ return toRoleDef(row);
544
+ },
545
+ async deleteRole(name) {
546
+ const role = await prisma.role.findUnique({ where: { name } });
547
+ if (!role) throw new Error("Role not found.");
548
+ if (role.isSystem) throw new Error("System roles can't be deleted.");
549
+ const inUse = await prisma.user.count({ where: { role: name } });
550
+ if (inUse > 0) {
551
+ throw new Error(
552
+ `Can't delete "${role.label}" \u2014 it's assigned to ${inUse} account(s).`
553
+ );
554
+ }
555
+ await prisma.role.delete({ where: { name } });
556
+ },
557
+ // ── Activity log (GE-022) ───────────────────────────────────────────────
558
+ async logActivity(entry) {
559
+ await prisma.activityLog.create({
560
+ data: {
561
+ actorId: entry.actorId ?? null,
562
+ actorUsername: entry.actorUsername ?? null,
563
+ action: entry.action,
564
+ summary: entry.summary,
565
+ targetType: entry.targetType ?? null,
566
+ targetId: entry.targetId ?? null
567
+ }
568
+ });
569
+ },
570
+ async listActivity(filter) {
571
+ const where = {};
572
+ if (filter?.action) where.action = filter.action;
573
+ if (filter?.actorId) where.actorId = filter.actorId;
574
+ if (filter?.fromISO || filter?.toISO) {
575
+ const range = {};
576
+ if (filter.fromISO) range.gte = new Date(filter.fromISO);
577
+ if (filter.toISO) range.lte = new Date(filter.toISO);
578
+ where.createdAt = range;
579
+ }
580
+ const rows = await prisma.activityLog.findMany({
581
+ where,
582
+ orderBy: { createdAt: "desc" },
583
+ take: Math.min(Math.max(filter?.limit ?? 200, 1), 1e3)
584
+ });
585
+ return rows.map((r) => ({
586
+ id: r.id,
587
+ actorId: r.actorId ?? null,
588
+ actorUsername: r.actorUsername ?? null,
589
+ action: r.action,
590
+ summary: r.summary,
591
+ targetType: r.targetType ?? void 0,
592
+ targetId: r.targetId ?? void 0,
593
+ createdAt: r.createdAt.toISOString()
594
+ }));
342
595
  }
343
596
  };
344
597
  return store;
@@ -1,6 +1,18 @@
1
1
  // ../ovr-data/src/seed-runner.ts
2
2
  async function seedRunner(prisma, data) {
3
- const { catalog, officers, tickets, users, passwordHash } = data;
3
+ const { catalog, officers, tickets, users, roles, passwordHash } = data;
4
+ for (const r of roles) {
5
+ await prisma.role.upsert({
6
+ where: { name: r.name },
7
+ update: { label: r.label, isSystem: r.isSystem, permissions: r.permissions },
8
+ create: {
9
+ name: r.name,
10
+ label: r.label,
11
+ isSystem: r.isSystem,
12
+ permissions: r.permissions
13
+ }
14
+ });
15
+ }
4
16
  for (const o of officers) {
5
17
  await prisma.officer.upsert({
6
18
  where: { id: o.id },
@@ -77,21 +89,12 @@ async function seedRunner(prisma, data) {
77
89
  });
78
90
  }
79
91
  for (const u of users) {
92
+ const role = u.role ?? "ENFORCER";
93
+ const officerId = u.officerId ?? null;
80
94
  await prisma.user.upsert({
81
95
  where: { username: u.username },
82
- update: {
83
- passwordHash,
84
- role: "ENFORCER",
85
- active: true,
86
- officerId: u.officerId
87
- },
88
- create: {
89
- username: u.username,
90
- passwordHash,
91
- role: "ENFORCER",
92
- active: true,
93
- officerId: u.officerId
94
- }
96
+ update: { passwordHash, role, active: true, officerId },
97
+ create: { username: u.username, passwordHash, role, active: true, officerId }
95
98
  });
96
99
  }
97
100
  await prisma.$executeRawUnsafe(`
@@ -102,7 +105,7 @@ async function seedRunner(prisma, data) {
102
105
  )
103
106
  `);
104
107
  console.log(
105
- `\u2713 Seed complete: ${officers.length} officers, ${catalog.length} catalog items, ${tickets.length} tickets, ${users.length} users.`
108
+ `\u2713 Seed complete: ${roles.length} roles, ${officers.length} officers, ${catalog.length} catalog items, ${tickets.length} tickets, ${users.length} users.`
106
109
  );
107
110
  }
108
111