@marinoscar/kvox-eval 0.0.0-bootstrap → 0.1.0-next.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 (245) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/LICENSE +21 -0
  3. package/README.md +166 -0
  4. package/dist/ask/index.d.ts +6 -0
  5. package/dist/ask/index.js +28 -0
  6. package/dist/ask/question-schema.d.ts +121 -0
  7. package/dist/ask/question-schema.js +167 -0
  8. package/dist/ask/report.d.ts +88 -0
  9. package/dist/ask/report.js +170 -0
  10. package/dist/ask/scorer.d.ts +104 -0
  11. package/dist/ask/scorer.js +164 -0
  12. package/dist/ask-run/ask-command.d.ts +62 -0
  13. package/dist/ask-run/ask-command.js +254 -0
  14. package/dist/ask-run/host/app.d.ts +65 -0
  15. package/dist/ask-run/host/app.js +146 -0
  16. package/dist/ask-run/host/db.d.ts +38 -0
  17. package/dist/ask-run/host/db.js +126 -0
  18. package/dist/ask-run/host/ports.d.ts +35 -0
  19. package/dist/ask-run/host/ports.js +113 -0
  20. package/dist/ask-run/index.d.ts +13 -0
  21. package/dist/ask-run/index.js +35 -0
  22. package/dist/ask-run/replay.d.ts +128 -0
  23. package/dist/ask-run/replay.js +212 -0
  24. package/dist/ask-run/run-ask-eval.d.ts +125 -0
  25. package/dist/ask-run/run-ask-eval.js +193 -0
  26. package/dist/ask-run/seed-golden-graph.d.ts +64 -0
  27. package/dist/ask-run/seed-golden-graph.js +495 -0
  28. package/dist/cli/index.d.ts +3 -0
  29. package/dist/cli/index.js +12 -0
  30. package/dist/cli/kg-command.d.ts +66 -0
  31. package/dist/cli/kg-command.js +238 -0
  32. package/dist/cli/main.d.ts +18 -0
  33. package/dist/cli/main.js +69 -0
  34. package/dist/fixtures/domains.d.ts +16 -0
  35. package/dist/fixtures/domains.js +33 -0
  36. package/dist/fixtures/fixture-schema.d.ts +323 -0
  37. package/dist/fixtures/fixture-schema.js +199 -0
  38. package/dist/fixtures/index.d.ts +16 -0
  39. package/dist/fixtures/index.js +40 -0
  40. package/dist/fixtures/load.d.ts +73 -0
  41. package/dist/fixtures/load.js +137 -0
  42. package/dist/fixtures/questions.d.ts +15 -0
  43. package/dist/fixtures/questions.js +26 -0
  44. package/dist/index.d.ts +20 -0
  45. package/dist/index.js +36 -0
  46. package/dist/kg/gold-runner.d.ts +13 -0
  47. package/dist/kg/gold-runner.js +57 -0
  48. package/dist/kg/index.d.ts +17 -0
  49. package/dist/kg/index.js +65 -0
  50. package/dist/kg/internals.d.ts +7 -0
  51. package/dist/kg/internals.js +17 -0
  52. package/dist/kg/prediction-schema.d.ts +245 -0
  53. package/dist/kg/prediction-schema.js +102 -0
  54. package/dist/kg/predictions.d.ts +11 -0
  55. package/dist/kg/predictions.js +49 -0
  56. package/dist/kg/report.d.ts +84 -0
  57. package/dist/kg/report.js +125 -0
  58. package/dist/kg/run-kg-eval.d.ts +77 -0
  59. package/dist/kg/run-kg-eval.js +137 -0
  60. package/dist/kg/runner.d.ts +72 -0
  61. package/dist/kg/runner.js +51 -0
  62. package/dist/kg/runners/extract-resolve-runner.d.ts +40 -0
  63. package/dist/kg/runners/extract-resolve-runner.js +199 -0
  64. package/dist/kg/runners/extract-runner.d.ts +65 -0
  65. package/dist/kg/runners/extract-runner.js +243 -0
  66. package/dist/kg/scorer.d.ts +226 -0
  67. package/dist/kg/scorer.js +463 -0
  68. package/dist/kg/targets.d.ts +17 -0
  69. package/dist/kg/targets.js +16 -0
  70. package/dist/kg/text.d.ts +45 -0
  71. package/dist/kg/text.js +100 -0
  72. package/dist/live/adapters.d.ts +16 -0
  73. package/dist/live/adapters.js +58 -0
  74. package/dist/live/env.d.ts +39 -0
  75. package/dist/live/env.js +56 -0
  76. package/dist/live/index.d.ts +6 -0
  77. package/dist/live/index.js +16 -0
  78. package/dist/live/live-ai.d.ts +104 -0
  79. package/dist/live/live-ai.js +157 -0
  80. package/dist/parity/constants.d.ts +79 -0
  81. package/dist/parity/constants.js +104 -0
  82. package/dist/parity/index.d.ts +20 -0
  83. package/dist/parity/index.js +38 -0
  84. package/dist/parity/internals.d.ts +8 -0
  85. package/dist/parity/internals.js +16 -0
  86. package/dist/parity/scenario.d.ts +45 -0
  87. package/dist/parity/scenario.js +245 -0
  88. package/dist/parity/script.d.ts +44 -0
  89. package/dist/parity/script.js +136 -0
  90. package/dist/parity/snapshot.d.ts +43 -0
  91. package/dist/parity/snapshot.js +71 -0
  92. package/dist/real-data/index.d.ts +2 -0
  93. package/dist/real-data/index.js +12 -0
  94. package/dist/real-data/real-data.d.ts +76 -0
  95. package/dist/real-data/real-data.js +156 -0
  96. package/fixtures/kg-golden/README.md +136 -0
  97. package/fixtures/kg-golden/ask-questions.json +458 -0
  98. package/fixtures/kg-golden/meetings/m01-northwind-pilot-kickoff.json +792 -0
  99. package/fixtures/kg-golden/meetings/m02-halden-migration-planning.json +814 -0
  100. package/fixtures/kg-golden/meetings/m03-larkspur-weekly-sync.json +601 -0
  101. package/fixtures/kg-golden/meetings/m04-corvid-intake-discovery.json +690 -0
  102. package/fixtures/kg-golden/meetings/m05-hosting-vendor-selection.json +631 -0
  103. package/fixtures/kg-golden/meetings/m06-northwind-floor-data-review.json +649 -0
  104. package/fixtures/kg-golden/meetings/m07-gullwing-intro-notes.json +428 -0
  105. package/fixtures/kg-golden/meetings/m08-halden-migration-checkpoint.json +672 -0
  106. package/fixtures/kg-golden/meetings/m09-northwind-platform-org-update.json +711 -0
  107. package/fixtures/kg-golden/meetings/m10-corvid-prototype-review.json +623 -0
  108. package/fixtures/kg-golden/meetings/m11-solvane-grid-dashboard-scoping.json +689 -0
  109. package/fixtures/kg-golden/meetings/m12-northwind-pilot-replan.json +681 -0
  110. package/fixtures/kg-golden/meetings/m13-halden-cutover-go-no-go.json +721 -0
  111. package/fixtures/kg-golden/meetings/m14-gullwing-discovery-readout.json +719 -0
  112. package/fixtures/kg-golden/meetings/m15-halden-handover-ravi-leaving.json +716 -0
  113. package/fixtures/kg-golden/meetings/m16-varden-claims-portal-intro.json +704 -0
  114. package/fixtures/kg-golden/meetings/m17-corvid-usability-debrief.json +572 -0
  115. package/fixtures/kg-golden/meetings/m18-hosting-decision-revisited.json +655 -0
  116. package/fixtures/kg-golden/meetings/m19-solvane-integration-kickoff.json +726 -0
  117. package/fixtures/kg-golden/meetings/m20-northwind-go-live-retro.json +673 -0
  118. package/fixtures/kg-golden/meetings/m21-quarterly-account-review.json +449 -0
  119. package/fixtures/kg-golden/meetings/m22-halden-intro-call-2020.json +558 -0
  120. package/fixtures/kg-golden/meetings/m23-gullwing-model-review.json +702 -0
  121. package/fixtures/kg-golden/meetings/m24-varden-discovery-midpoint.json +643 -0
  122. package/fixtures/kg-golden/meetings/m25-corvid-rollout-planning.json +642 -0
  123. package/fixtures/kg-golden/meetings/m26-larkspur-engineering-prep.json +695 -0
  124. package/fixtures/kg-golden/meetings/m27-solvane-phase-one-demo.json +649 -0
  125. package/fixtures/kg-golden/meetings/m28-northwind-building-5-scoping.json +715 -0
  126. package/fixtures/kg-golden/meetings/m29-halden-southern-cutover-review.json +752 -0
  127. package/fixtures/kg-golden/meetings/m30-varden-portal-build-kickoff.json +697 -0
  128. package/fixtures/kg-golden/meetings/m31-gullwing-rollout-renewal.json +681 -0
  129. package/fixtures/kg-golden/meetings/m32-brambleway-weekly-sync.json +472 -0
  130. package/fixtures/kg-golden/meetings/m33-nina-oliver-one-on-one.json +342 -0
  131. package/fixtures/kg-golden/meetings/m34-coffee-with-dana.json +249 -0
  132. package/fixtures/kg-golden/meetings/m35-brambleway-june-sync.json +553 -0
  133. package/fixtures/kg-golden/meetings/m36-copenhagen-conference-planning.json +296 -0
  134. package/host/generated/client.d.ts +1 -0
  135. package/host/generated/client.js +5 -0
  136. package/host/generated/default.d.ts +1 -0
  137. package/host/generated/default.js +5 -0
  138. package/host/generated/edge.d.ts +1 -0
  139. package/host/generated/edge.js +1658 -0
  140. package/host/generated/index-browser.js +1684 -0
  141. package/host/generated/index.d.ts +192335 -0
  142. package/host/generated/index.js +1658 -0
  143. package/host/generated/package.json +139 -0
  144. package/host/generated/query_compiler_fast_bg.js +2 -0
  145. package/host/generated/query_compiler_fast_bg.wasm +0 -0
  146. package/host/generated/query_compiler_fast_bg.wasm-base64.js +2 -0
  147. package/host/generated/runtime/client.d.ts +3433 -0
  148. package/host/generated/runtime/client.js +110 -0
  149. package/host/generated/runtime/index-browser.d.ts +90 -0
  150. package/host/generated/runtime/index-browser.js +6 -0
  151. package/host/generated/runtime/wasm-compiler-edge.js +100 -0
  152. package/host/generated/schema.prisma +6312 -0
  153. package/host/generated/wasm-edge-light-loader.mjs +5 -0
  154. package/host/generated/wasm-worker-loader.mjs +5 -0
  155. package/host/prisma/fragments/kvox.ask.prisma +126 -0
  156. package/host/prisma/fragments/kvox.graph.prisma +1073 -0
  157. package/host/prisma/fragments/kvox.notes.prisma +787 -0
  158. package/host/prisma/fragments/kvox.search.prisma +211 -0
  159. package/host/prisma/fragments/kvox.transcripts.prisma +790 -0
  160. package/host/prisma/kvox.lock +101 -0
  161. package/host/prisma/migrations/20240101000000_initial/migration.sql +297 -0
  162. package/host/prisma/migrations/20240101000001_add_personal_access_tokens/migration.sql +31 -0
  163. package/host/prisma/migrations/20240101000002_add_credentials/migration.sql +20 -0
  164. package/host/prisma/migrations/20240101000003_add_notification_deliveries/migration.sql +27 -0
  165. package/host/prisma/migrations/20240101000004_drop_stale_uuid_defaults/migration.sql +35 -0
  166. package/host/prisma/migrations/20240101000005_add_notifications/migration.sql +22 -0
  167. package/host/prisma/migrations/20240101000006_add_push_subscriptions/migration.sql +25 -0
  168. package/host/prisma/migrations/20240101000007_add_jobs/migration.sql +110 -0
  169. package/host/prisma/migrations/20240101000008_add_worker_nodes/migration.sql +90 -0
  170. package/host/prisma/migrations/20240101000009_add_database_backup_runs/migration.sql +104 -0
  171. package/host/prisma/migrations/20240101000010_add_notification_broadcasts/migration.sql +57 -0
  172. package/host/prisma/migrations/20240101000011_add_backup_run_job_link/migration.sql +87 -0
  173. package/host/prisma/migrations/20240101000012_add_job_node_secrets/migration.sql +61 -0
  174. package/host/prisma/migrations/20240101000013_add_backup_run_pg_dump_version/migration.sql +26 -0
  175. package/host/prisma/migrations/20240101000014_add_job_claim_token/migration.sql +92 -0
  176. package/host/prisma/migrations/20240101000015_add_ai_platform/migration.sql +108 -0
  177. package/host/prisma/migrations/20240101000016_revoke_viewer_ai_use/migration.sql +32 -0
  178. package/host/prisma/migrations/20240101000017_add_user_credentials/migration.sql +29 -0
  179. package/host/prisma/migrations/20240101000018_add_device_session_credential_link/migration.sql +52 -0
  180. package/host/prisma/migrations/20240101000019_add_worker_node_vitals/migration.sql +3 -0
  181. package/host/prisma/migrations/20240101000020_add_job_trace_context/migration.sql +6 -0
  182. package/host/prisma/migrations/20240101000021_add_retention_created_at_indexes/migration.sql +13 -0
  183. package/host/prisma/migrations/20240101000022_add_organizations/migration.sql +142 -0
  184. package/host/prisma/migrations/20240101000023_split_system_org_roles/migration.sql +131 -0
  185. package/host/prisma/migrations/20240101000024_org_scoped_rls/migration.sql +215 -0
  186. package/host/prisma/migrations/20240101000025_add_groups/migration.sql +165 -0
  187. package/host/prisma/migrations/20240101000026_add_grants/migration.sql +117 -0
  188. package/host/prisma/migrations/20240101000027_add_org_settings/migration.sql +49 -0
  189. package/host/prisma/migrations/20240101000028_add_org_credentials/migration.sql +54 -0
  190. package/host/prisma/migrations/20240101000029_add_jobs_org_id/migration.sql +18 -0
  191. package/host/prisma/migrations/20240101000030_add_jobs_org_id_status_index/migration.sql +6 -0
  192. package/host/prisma/migrations/20240101000031_add_broadcast_target_org/migration.sql +21 -0
  193. package/host/prisma/migrations/20240101000032_add_android_app/migration.sql +60 -0
  194. package/host/prisma/migrations/20240101000033_open_job_reason/migration.sql +16 -0
  195. package/host/prisma/migrations/20240101000034_ai_usage_feature/migration.sql +37 -0
  196. package/host/prisma/migrations/20240101000035_add_app_metadata/migration.sql +49 -0
  197. package/host/prisma/migrations/20240101000036_open_sharing_vocabularies/migration.sql +97 -0
  198. package/host/prisma/migrations/20260914130000_kvox_add_transcripts/migration.sql +322 -0
  199. package/host/prisma/migrations/20260914140000_kvox_add_notes/migration.sql +311 -0
  200. package/host/prisma/migrations/20260915120000_kvox_add_search_vectors/migration.sql +150 -0
  201. package/host/prisma/migrations/20260915130000_kvox_add_search_embeddings/migration.sql +362 -0
  202. package/host/prisma/migrations/20260915210000_kvox_add_note_title_source/migration.sql +30 -0
  203. package/host/prisma/migrations/20260923120000_kvox_add_note_generation_context_snapshot/migration.sql +24 -0
  204. package/host/prisma/migrations/20260923130000_kvox_add_user_hidden_note_templates/migration.sql +35 -0
  205. package/host/prisma/migrations/20260923140000_kvox_add_transcript_speaker_identities/migration.sql +6 -0
  206. package/host/prisma/migrations/20260923150000_kvox_transcript_name_checks/migration.sql +95 -0
  207. package/host/prisma/migrations/20260923160000_kvox_add_note_body_format/migration.sql +27 -0
  208. package/host/prisma/migrations/20260923170000_kvox_add_note_version_body_format/migration.sql +23 -0
  209. package/host/prisma/migrations/20260926012548_kvox_add_knowledge_graph/migration.sql +680 -0
  210. package/host/prisma/migrations/20260926030000_kvox_fix_kg_relations_valid_precision_chk/migration.sql +29 -0
  211. package/host/prisma/migrations/20260926030100_kvox_add_transcript_recorded_at/migration.sql +9 -0
  212. package/host/prisma/migrations/20260926030200_kvox_kg_evidence_invariant/migration.sql +60 -0
  213. package/host/prisma/migrations/20260926040000_kvox_add_kg_graph_layouts/migration.sql +30 -0
  214. package/host/prisma/migrations/20260926050000_kvox_add_ask_conversations/migration.sql +99 -0
  215. package/host/prisma/migrations/20260926231512_kvox_add_kg_exports/migration.sql +52 -0
  216. package/host/prisma/migrations/20261016120000_kvox_transcripts_org_id/migration.sql +49 -0
  217. package/host/prisma/platform.lock +319 -0
  218. package/host/prisma/schema/app.kvox.ask.prisma +110 -0
  219. package/host/prisma/schema/app.kvox.graph.prisma +1030 -0
  220. package/host/prisma/schema/app.kvox.notes.prisma +746 -0
  221. package/host/prisma/schema/app.kvox.search.prisma +208 -0
  222. package/host/prisma/schema/app.kvox.transcripts.prisma +726 -0
  223. package/host/prisma/schema/platform.ai.prisma +419 -0
  224. package/host/prisma/schema/platform.android-app.prisma +46 -0
  225. package/host/prisma/schema/platform.base.prisma +15 -0
  226. package/host/prisma/schema/platform.credentials.prisma +156 -0
  227. package/host/prisma/schema/platform.db-backup.prisma +359 -0
  228. package/host/prisma/schema/platform.identity.prisma +638 -0
  229. package/host/prisma/schema/platform.jobs.prisma +824 -0
  230. package/host/prisma/schema/platform.notifications.prisma +641 -0
  231. package/host/prisma/schema/platform.settings.prisma +53 -0
  232. package/host/prisma/schema/platform.sharing.prisma +208 -0
  233. package/host/prisma/schema/platform.storage.prisma +118 -0
  234. package/package.json +112 -1
  235. package/parity/requests.ask.json +2681 -0
  236. package/parity/requests.m01.json +1554 -0
  237. package/parity/requests.m02.json +1554 -0
  238. package/parity/requests.m03.json +1554 -0
  239. package/parity/requests.m04.json +1554 -0
  240. package/parity/requests.m05.json +1554 -0
  241. package/parity/requests.m06.json +1554 -0
  242. package/parity/requests.m07.json +1554 -0
  243. package/parity/requests.m08.json +1554 -0
  244. package/parity/requests.m09.json +1554 -0
  245. package/parity/requests.m10.json +1554 -0
@@ -0,0 +1,641 @@
1
+ // GENERATED by platform db compose — do not edit
2
+ // source: package:notifications.prisma
3
+
4
+ // =============================================================================
5
+ // Notification Deliveries (issue #125, epic #109)
6
+ // =============================================================================
7
+ //
8
+ // One row per delivery ATTEMPT of an event (#121, `notification-events.ts`)
9
+ // over a channel, to a recipient. This is the answer to "did the user
10
+ // actually get it?" — the first question asked when something goes wrong,
11
+ // and epic #109's own success criterion 7: a delivery record exists for
12
+ // every attempt, with the provider's message id or error.
13
+ //
14
+ // -----------------------------------------------------------------------------
15
+ // eventKey — plain string, NOT a foreign key
16
+ // -----------------------------------------------------------------------------
17
+ // Events are declared in code (`notification-events.ts`, #121), not in a
18
+ // database table — `findEvent()` there returns `undefined` rather than
19
+ // throwing for a key it doesn't recognize, precisely because callers may be
20
+ // holding a key that came from persisted data written before the event was
21
+ // renamed or removed. An FK into an "events" table would force a migration
22
+ // (and a backfill, or a placeholder row) every time the registry changes,
23
+ // which is exactly the coupling #121 built the registry to avoid. A delivery
24
+ // record must outlive the event definition that produced it.
25
+ //
26
+ // -----------------------------------------------------------------------------
27
+ // channel — ALSO a plain string, not a Prisma enum (deliberately asymmetric
28
+ // with `status` below)
29
+ // -----------------------------------------------------------------------------
30
+ // `NotificationChannel` in `notification-events.ts` is explicitly derived
31
+ // from a plain array (`NOTIFICATION_CHANNELS`) so that adding `'push'` later
32
+ // (epic #109 reserves it) costs one edit to that array, not a migration. A
33
+ // Postgres enum on this column would quietly reintroduce that migration: the
34
+ // set of valid channels would live in two places — the code registry and
35
+ // this column's type — and adding one would require updating both. The
36
+ // registry is the single source of truth for what channels exist; this
37
+ // column just records which one a given attempt used.
38
+ //
39
+ // -----------------------------------------------------------------------------
40
+ // status — IS a Prisma enum
41
+ // -----------------------------------------------------------------------------
42
+ // Unlike eventKey/channel, `status` is not registry data — it is this
43
+ // table's own delivery state machine, owned entirely by the dispatcher, and
44
+ // nothing outside this table grows it. It follows the convention this schema
45
+ // already uses for exactly this shape of field (`DeviceCodeStatus`,
46
+ // `StorageObjectStatus`): a small, closed, code-owned set of states is a
47
+ // Postgres enum here, so the database itself rejects a delivery row written
48
+ // with anything other than queued/sent/failed.
49
+ //
50
+ // -----------------------------------------------------------------------------
51
+ // userId — nullable; recipient — the actual destination used
52
+ // -----------------------------------------------------------------------------
53
+ // `allowlist.invitation` (#121) is sent to an email address with no user
54
+ // account yet (#128) — there is no user row to point to, so `userId` must be
55
+ // nullable. `recipient` is what makes that case complete: it is the actual
56
+ // destination address used for THIS delivery, captured independently of the
57
+ // user relation, so the record stands on its own with `userId` null, and
58
+ // stays historically accurate even if the user's email later changes.
59
+ //
60
+ // -----------------------------------------------------------------------------
61
+ // onDelete: SetNull on the user relation
62
+ // -----------------------------------------------------------------------------
63
+ // Matches the audit-trail precedent this schema already sets for
64
+ // `AuditEvent.actorUserId` and `Credential.updatedByUser`: a delivery record
65
+ // is a history of what the application sent, and deleting a user account
66
+ // should not retroactively erase the evidence of what was sent to them —
67
+ // particularly for `security.*` events (#121), which are exactly the ones an
68
+ // incident review is most likely to need after the account in question no
69
+ // longer exists. Nothing is lost by nulling the FK: `recipient` already
70
+ // carries the actual destination independently of it. A GDPR-style request
71
+ // to purge one user's PII is a deliberate, targeted scrub of these rows (and
72
+ // `recipient` specifically) — not something an ON DELETE clause should do
73
+ // silently and irreversibly as a side effect of an unrelated user-deletion
74
+ // path elsewhere in the app.
75
+ //
76
+ // -----------------------------------------------------------------------------
77
+ // Indexes — exactly the two queries this table is known to serve
78
+ // -----------------------------------------------------------------------------
79
+ // Every index is a write cost on a table that grows with every notification
80
+ // sent, so nothing speculative is added beyond what #125 itself names:
81
+ // - [userId, eventKey]: "did this user get this event?" — an equality
82
+ // filter on both columns, from an account's delivery history view.
83
+ // - [status, createdAt]: "what failed recently?" — status = 'failed'
84
+ // filtered and ordered by recency, for on-call triage.
85
+ // @extensible — back-relations only, added with "extend model NotificationDelivery { ... }" in a
86
+ // fragment (an app's own delivery annotations, for example a provider-specific receipt).
87
+ // The delivery ledger itself (status, error, provider id) stays the platform's.
88
+ model NotificationDelivery {
89
+ id String @id @default(uuid()) @db.Uuid
90
+ eventKey String @map("event_key")
91
+ userId String? @map("user_id") @db.Uuid
92
+ recipient String
93
+ channel String
94
+ status NotificationDeliveryStatus @default(queued)
95
+ providerMessageId String? @map("provider_message_id")
96
+ // Provider errors (SMTP rejections, SES API errors, ...) have no useful
97
+ // length bound — @db.Text, same reasoning as Credential.secret above.
98
+ error String? @db.Text
99
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
100
+ updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
101
+
102
+ // Relations
103
+ user User? @relation("UserNotificationDeliveries", fields: [userId], references: [id], onDelete: SetNull)
104
+
105
+ @@index([userId, eventKey])
106
+ @@index([status, createdAt])
107
+ // The retention sweep (`notifications.deliveries.purge`, #681): "oldest
108
+ // first, older than the cutoff", which the status-leading index above
109
+ // serves only one status at a time.
110
+ @@index([createdAt])
111
+ @@map("notification_deliveries")
112
+ }
113
+
114
+ enum NotificationDeliveryStatus {
115
+ queued
116
+ sent
117
+ failed
118
+ }
119
+
120
+ // =============================================================================
121
+ // Notifications — the in-app notification centre store (issue #127, epic #109)
122
+ // =============================================================================
123
+ //
124
+ // THE PRIMARY SURFACE OF THE BROWSER CHANNEL, NOT A CACHE OF WHAT SSE SENT.
125
+ //
126
+ // That ordering is the whole reason this table exists. `Notification`
127
+ // permission is denied by a large share of users, and once denied it is
128
+ // effectively permanent — the page cannot re-prompt. A browser channel that
129
+ // existed only as an OS toast would therefore not exist at all for those
130
+ // accounts. So the durable, per-user list is the feature; the SSE stream
131
+ // (`notification-stream.service.ts`) makes it live, and the native toast is a
132
+ // decoration on top that degrades to nothing.
133
+ //
134
+ // Read the second consequence too: SSE IS NOT A DELIVERY GUARANTEE. Events
135
+ // published while a tab is disconnected are lost — `EventSource` reconnects
136
+ // but replays nothing, and the per-process subscriber registry does not fan
137
+ // out across replicas. Because rows land here first and the stream is
138
+ // published second, a client that refetches this table on (re)connect sees
139
+ // everything regardless. That is the designed recovery path, and it is why
140
+ // there is no event-replay machinery anywhere in #127.
141
+ //
142
+ // -----------------------------------------------------------------------------
143
+ // HOW THIS DIFFERS FROM `notification_deliveries` (#125), WHICH IS NOT THE SAME
144
+ // TABLE WITH A DIFFERENT NAME
145
+ // -----------------------------------------------------------------------------
146
+ //
147
+ // `notification_deliveries` is the OPERATOR's record: one row per ATTEMPT, per
148
+ // channel, answering "did we try, and what did the provider say?". It records
149
+ // email attempts too, it keeps rows for recipients with no account, and it is
150
+ // never shown to the user it is about.
151
+ //
152
+ // `notifications` is the USER's inbox: one row per thing they should see, only
153
+ // for the browser channel, only for real accounts, with read state they own.
154
+ // An email send writes a delivery row and no row here — there is nothing for
155
+ // the bell to show about a message that went to their mailbox.
156
+ //
157
+ // Merging the two would mean either showing users provider error strings, or
158
+ // filtering an operator table by channel on every bell render. They answer
159
+ // different questions for different audiences and are kept apart.
160
+ // =============================================================================
161
+
162
+ // @extensible — back-relations only, added with "extend model Notification { ... }" in a
163
+ // fragment (an app's own side table keyed by a notification, for example a per-notification
164
+ // follow-up). A user-owned row: the table has no organization column, so a plain foreign
165
+ // key on `id` is the right shape. `onDelete: Cascade` on the app side keeps the retention
166
+ // sweep (`notifications.inbox.purge`) working.
167
+ model Notification {
168
+ id String @id @default(uuid()) @db.Uuid
169
+ // NOT NULL, unlike NotificationDelivery.userId. An inbox belongs to an
170
+ // account by definition; there is no "notification centre" for a recipient
171
+ // with no login (#128's `allowlist.invitation` case), which is exactly why
172
+ // the browser channel's resolveTo returns null for them.
173
+ userId String @map("user_id") @db.Uuid
174
+
175
+ // Plain string, NOT a foreign key — the same reasoning written out at length
176
+ // on NotificationDelivery.eventKey. Events are declared in code
177
+ // (`notification-events.ts`, #121) and `findEvent` returns undefined rather
178
+ // than throwing precisely because persisted rows outlive their definitions.
179
+ // An FK would force a migration every time the registry changed.
180
+ eventKey String @map("event_key")
181
+
182
+ // RENDERED AT WRITE TIME, not at read time. The alternative — store the
183
+ // event payload as JSONB and render in the bell — means an old row renders
184
+ // through today's template, so editing copy silently rewrites history and a
185
+ // removed event's rows become unrenderable. What the user was told is a
186
+ // fact; it is frozen here.
187
+ title String
188
+ // @db.Text for the same reason as Credential.secret and
189
+ // NotificationDelivery.error: a rendered body has no meaningful length
190
+ // bound, and silently truncating what a user was told is worse than storing
191
+ // a long string. (Postgres `text` has no performance penalty over `varchar`.)
192
+ body String @db.Text
193
+
194
+ // Where the bell should send the user, as a ROOT-RELATIVE PATH ("/settings",
195
+ // "/admin/users?id=…"), never an absolute URL.
196
+ //
197
+ // THIS IS A SECURITY BOUNDARY, ENFORCED IN CODE AT WRITE TIME
198
+ // (`browser-notification.channel.ts`). The web client will put this value in
199
+ // an href or a router navigation; an absolute URL makes every notification a
200
+ // potential open redirect, and a `javascript:` scheme makes it script
201
+ // execution in the app's own origin. The column cannot express that
202
+ // constraint, so the writer enforces it and drops a link it cannot vouch
203
+ // for rather than storing it and hoping the client is careful.
204
+ //
205
+ // Nullable: most notifications have nowhere specific to go.
206
+ link String?
207
+
208
+ // A TIMESTAMP, NOT A BOOLEAN. `readAt IS NULL` answers "is it unread?" just
209
+ // as well as `read = false` does, and additionally answers "when did they
210
+ // see it?" — which is the question asked when someone reports acting late on
211
+ // a security alert. A boolean would have to grow this column later anyway.
212
+ readAt DateTime? @map("read_at") @db.Timestamptz
213
+
214
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
215
+
216
+ // NO `updatedAt`. The only mutation this row ever undergoes is being marked
217
+ // read, and `readAt` already timestamps that with more precision than an
218
+ // `updatedAt` would carry. A second, redundant answer to "when did this
219
+ // change?" is a second thing to keep consistent for no reader.
220
+
221
+ // Relations
222
+ //
223
+ // onDelete: Cascade — deliberately the OPPOSITE of
224
+ // NotificationDelivery.user's SetNull, and the contrast is the point. A
225
+ // delivery record is an audit trail that must outlive the account it
226
+ // describes ("did we email this person before they were offboarded?"). An
227
+ // inbox is personal data with no reader once the account is gone: keeping
228
+ // orphaned rows would retain rendered titles and bodies about a deleted user
229
+ // forever, and `userId` is NOT NULL here so SetNull is not even expressible.
230
+ user User @relation("UserNotifications", fields: [userId], references: [id], onDelete: Cascade)
231
+
232
+ // The two queries this table actually serves, and nothing speculative.
233
+ //
234
+ // 1. THE UNREAD BADGE: `where { userId, readAt: null }`, run on every bell
235
+ // render and on every SSE (re)connect — by far the hottest query here.
236
+ // (Ideally a partial index `WHERE read_at IS NULL`, which Prisma cannot
237
+ // express declaratively; the composite covers the same predicate and
238
+ // stays in one place the schema owns.)
239
+ @@index([userId, readAt])
240
+ // 2. THE RECENT LIST: `where { userId } orderBy { createdAt: desc }`.
241
+ // `sort: Desc` matters — a plain ascending index makes Postgres walk it
242
+ // backwards for the newest page, which is the only page anyone loads.
243
+ @@index([userId, createdAt(sort: Desc)])
244
+ // The retention sweep (`notifications.inbox.purge`, #681): "oldest first,
245
+ // older than the cutoff" across every user, which neither index above serves.
246
+ @@index([createdAt])
247
+ @@map("notifications")
248
+ }
249
+
250
+ // =============================================================================
251
+ // Push Subscriptions (issue #229, epic #215)
252
+ // =============================================================================
253
+ //
254
+ // Storage for the browser's `PushSubscription` object — the endpoint URL plus
255
+ // the two keys (`p256dh`, `auth`) needed to encrypt a Web Push payload to that
256
+ // specific device — so the server has something to sign and send to when a
257
+ // notification's `browser` channel wants to reach a tab that isn't open.
258
+ //
259
+ // THIS SHIPS DISABLED BY DEFAULT (per #215). This table, and the endpoints the
260
+ // next pass builds on top of it, exist regardless; what makes push actually
261
+ // fire is a downstream deployment setting `VAPID_PUBLIC_KEY` /
262
+ // `VAPID_PRIVATE_KEY` in its own environment. A repo that never sets those
263
+ // vars has this table sitting empty and unused — generating keys, not writing
264
+ // code, is the enable step. Nothing here should ever require a schema change
265
+ // to turn push on.
266
+ //
267
+ // -----------------------------------------------------------------------------
268
+ // endpoint — @unique, not part of a composite key with userId
269
+ // -----------------------------------------------------------------------------
270
+ // The endpoint URL (assigned by the browser's push service — FCM, Mozilla's
271
+ // autopush, ...) already uniquely identifies one subscribed device/browser
272
+ // instance; it cannot simultaneously be valid for two different users. Making
273
+ // it @unique on its own, rather than @@unique([userId, endpoint]), is what
274
+ // lets the service layer `upsert` on `endpoint` alone (#229's acceptance
275
+ // criterion: "Re-subscribing the same endpoint upserts instead of creating a
276
+ // duplicate row") — including the case where the same browser subscription
277
+ // re-registers under a different signed-in user, which should move the row,
278
+ // not fork it.
279
+ //
280
+ // -----------------------------------------------------------------------------
281
+ // userId — onDelete: Cascade, the OPPOSITE of NotificationDelivery.user's
282
+ // SetNull right above, and deliberately so
283
+ // -----------------------------------------------------------------------------
284
+ // NotificationDelivery keeps its user relation nullable-with-SetNull because a
285
+ // delivery row is an audit trail with standalone value ("did we send this
286
+ // before the account was offboarded?") even once the account is gone — it
287
+ // carries its own `recipient` and stands on its own. A push subscription has
288
+ // no such standalone meaning: it is nothing but a live credential for pushing
289
+ // to one specific person's device. Once that person's account is deleted
290
+ // there is no one left to authorize a push, no one left to act on it, and no
291
+ // audit value in keeping an endpoint + key material around — it is simply
292
+ // dead. Cascade is correct here for the same reason `Notification.user` above
293
+ // is Cascade rather than SetNull: this is live, personal state with no reader
294
+ // once the account is gone, not history.
295
+ //
296
+ // -----------------------------------------------------------------------------
297
+ // failureCount / lastSuccessAt — written by #230, columns exist now
298
+ // -----------------------------------------------------------------------------
299
+ // Web Push endpoints go stale silently (uninstalled browser, revoked
300
+ // permission, expired subscription) and the push service reports this as a
301
+ // 404/410 (gone) or persistent 429/5xx on send, never as an explicit
302
+ // unsubscribe call. `failureCount` is incremented by the not-yet-built sender
303
+ // (#230) on each such failure so a pruning job can drop subscriptions that
304
+ // have stopped working, and `lastSuccessAt` is its complement — updated on
305
+ // every successful send — so "why did this stop working" has an answer
306
+ // (compare `failureCount`'s growth against how long ago `lastSuccessAt` was)
307
+ // without needing delivery-log archaeology. Neither column is written by
308
+ // anything in this migration; they exist now so #230 needs no schema change.
309
+ model PushSubscription {
310
+ id String @id @default(uuid()) @db.Uuid
311
+
312
+ userId String @map("user_id") @db.Uuid
313
+
314
+ // The push service URL the browser gave us (e.g.
315
+ // `https://fcm.googleapis.com/fcm/send/...`). @unique — see block comment
316
+ // above for why this, not a composite key, is what the upsert relies on.
317
+ endpoint String @unique
318
+
319
+ // The two halves of the browser's subscription keys
320
+ // (`PushSubscription.getKey('p256dh'|'auth')`, base64url-encoded), together
321
+ // sufficient to encrypt a payload to this device per the Web Push
322
+ // encryption spec (RFC 8291). Neither is secret in the same sense as
323
+ // `Credential.secret` — they are meaningless without also knowing the
324
+ // corresponding endpoint and the server's VAPID private key — so they are
325
+ // stored plain, matching how every Web Push library (including `web-push`,
326
+ // which the next pass will use) expects to read them back out.
327
+ p256dh String
328
+ auth String
329
+
330
+ // From the browser's `PushSubscription.expirationTime`, when the push
331
+ // service supplies one (most don't). Nullable because absence is the
332
+ // common case, not an error.
333
+ expirationTime DateTime? @map("expiration_time") @db.Timestamptz
334
+
335
+ // For diagnosing "why did this stop working" alongside failureCount/
336
+ // lastSuccessAt below. Nullable: not always available, and never required.
337
+ userAgent String? @map("user_agent")
338
+
339
+ // Incremented by #230's sender on delivery failure (429/5xx from the push
340
+ // service); read by its not-yet-built pruning logic to drop dead
341
+ // subscriptions. Starts at 0 and is reset to 0 on the next successful send.
342
+ failureCount Int @default(0) @map("failure_count")
343
+
344
+ // Updated on every successful send by #230. Nullable: a freshly created
345
+ // subscription has never had one.
346
+ lastSuccessAt DateTime? @map("last_success_at") @db.Timestamptz
347
+
348
+ // Which surface registered this subscription (#746, PP-9.4; harvested from
349
+ // EvoPath #312): `browser` (a browser tab or installed PWA) or `android_app`
350
+ // (the Android companion's Trusted Web Activity). The `android_app`
351
+ // notification channel pushes to these rows alone; `push` keeps meaning
352
+ // every subscription. A re-post of the same endpoint as `android_app`
353
+ // re-tags a `browser` row, a `browser` re-post never downgrades an
354
+ // `android_app` row (push-subscription.service.ts). The CHECK constraint
355
+ // `push_subscriptions_platform_check` lives in the migration SQL only
356
+ // (Prisma cannot express one; see PUSH_SUBSCRIPTION_PLATFORMS in the
357
+ // notifications contract).
358
+ platform String @default("browser")
359
+
360
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
361
+ updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
362
+
363
+ // Relations
364
+ //
365
+ // onDelete: Cascade — see block comment above for why this reverses the
366
+ // SetNull precedent set by NotificationDelivery.user just above.
367
+ user User @relation("UserPushSubscriptions", fields: [userId], references: [id], onDelete: Cascade)
368
+
369
+ // The one query this table is known to serve: "give me this user's
370
+ // subscriptions to push to" (#229's subscribe/unsubscribe endpoints and
371
+ // #230's send fan-out both key off userId; endpoint's own @unique index
372
+ // already covers lookup/upsert by endpoint).
373
+ @@index([userId])
374
+ @@map("push_subscriptions")
375
+ }
376
+
377
+ // =============================================================================
378
+ // Notification Broadcasts (issue #320, epic #319)
379
+ // =============================================================================
380
+ //
381
+ // One row per admin-composed message announced to every user in the
382
+ // deployment — "we're doing maintenance Saturday", "new feature shipped" —
383
+ // scheduled, sent (or canceled), and never re-editable once it has started.
384
+ // This issue is schema only: the compose/schedule/cancel endpoints, the job
385
+ // handler that actually fans a broadcast out over `notify()`, and the admin
386
+ // UI all arrive in later issues of #319. What is here is the row a later
387
+ // issue's job reads from and writes progress back to.
388
+ //
389
+ // -----------------------------------------------------------------------------
390
+ // WHY A TABLE AT ALL — REJECTED: living entirely inside `Job.payload`
391
+ // -----------------------------------------------------------------------------
392
+ // The obvious shortcut is to skip this table and let a broadcast be nothing
393
+ // but the `payload` JSON of a `'broadcast.send'` job (#255 already gives
394
+ // every job a JSONB payload for free). Two things rule that out:
395
+ //
396
+ // - Job history is pruned INDEPENDENTLY of this table's business value.
397
+ // `job-history-purge` (#263) deletes old `jobs` rows on a retention
398
+ // schedule that exists to keep the QUEUE small, not to preserve what an
399
+ // admin once announced. A broadcast composed ninety days ago is still
400
+ // something "what did we tell our users, and when" needs to answer long
401
+ // after the job that sent it has aged out — living only in `payload`
402
+ // means that answer silently disappears on a retention sweep nobody
403
+ // doing the asking even knows runs.
404
+ // - The admin broadcast list page (a later issue in #319) would be
405
+ // QUERYING THE QUEUE FOR BUSINESS DATA — filtering and paging `jobs` by a
406
+ // `type` string and reading business fields back out of an opaque JSONB
407
+ // blob never meant to be a list-page's row shape. `jobs` is
408
+ // infrastructure for background work in general; a broadcast is a
409
+ // first-class thing the product has, with its own status, its own
410
+ // progress counters, and its own list page. It gets its own table for the
411
+ // same reason `notifications` (#127) is not "SSE events kept in a JSONB
412
+ // column somewhere" — the durable record IS the feature, not a
413
+ // side-effect of how the delivery mechanism happens to be implemented.
414
+ //
415
+ // A `'broadcast.send'` job still exists and still references one of these
416
+ // rows (by id, in its `payload`) to actually run the fan-out — this table is
417
+ // not a replacement for the queue, only for using the queue's own history as
418
+ // the system of record.
419
+ //
420
+ // -----------------------------------------------------------------------------
421
+ // channels — String[], not a Prisma enum
422
+ // -----------------------------------------------------------------------------
423
+ // Mirrors `NotificationDelivery.channel`'s deliberate plain-string choice
424
+ // (see that column's own block comment above), one level up: a broadcast can
425
+ // target more than one channel at once, so this is `channels String[]`
426
+ // rather than a single string, but the "why not an enum" reasoning is
427
+ // identical. `NOTIFICATION_CHANNELS` (`notification-events.ts`) is a
428
+ // code-owned, growing array specifically so adding `'push'`-style channels
429
+ // later costs one edit to that array. A Postgres enum on this column would
430
+ // reintroduce exactly the migration that array exists to avoid, and would
431
+ // leave the set of valid channels living in two places that could disagree.
432
+ //
433
+ // -----------------------------------------------------------------------------
434
+ // title / body — composed and frozen at write time, not templated at read
435
+ // time
436
+ // -----------------------------------------------------------------------------
437
+ // Same contract as `Notification.title`/`.body` (#127): what an admin typed
438
+ // (or the compose form rendered from it) is a fact about what was announced,
439
+ // stored as that fact, not as a template reference re-rendered later. The
440
+ // alternative — store a template key plus a data blob and render in the
441
+ // list page — means editing copy elsewhere in the app silently rewrites what
442
+ // this broadcast is remembered as having said, and a broadcast survives any
443
+ // future rename or removal of whatever generated its text.
444
+ //
445
+ // -----------------------------------------------------------------------------
446
+ // status — `NotificationBroadcastStatus`, matching `NotificationDeliveryStatus`'s
447
+ // naming, and carrying one member NOTHING IN THIS EPIC CAN REACH YET
448
+ // -----------------------------------------------------------------------------
449
+ // The enum is prefixed `NotificationBroadcast…` rather than plain
450
+ // `BroadcastStatus` to match the existing `NotificationDeliveryStatus`
451
+ // naming convention on the sibling tables in this section, rather than
452
+ // inventing a shorter name unique to this model.
453
+ //
454
+ // `draft` IS NOT REACHABLE BY ANY ROUTE #319 BUILDS. No compose endpoint in
455
+ // this epic writes a row with that status, and no later issue's handler
456
+ // reads one. It is here anyway, on purpose, because adding a value to a
457
+ // Postgres enum type is a migration, and `ALTER TYPE ... ADD VALUE` cannot
458
+ // run inside the same transaction Prisma wraps a migration in (Postgres
459
+ // forbids using a freshly added enum value in the transaction that added
460
+ // it). A future "save and finish composing later" feature is a one-line
461
+ // product ask that would otherwise cost a migration on a table with live
462
+ // data; paying for the enum slot now, while it is free, buys that escape
463
+ // hatch later for nothing. DO NOT DELETE `draft` AS "unused" — it is
464
+ // deliberately dead code, not leftover code.
465
+ //
466
+ // -----------------------------------------------------------------------------
467
+ // cursorUserId / audienceCutoff — the resume point AND the frozen audience
468
+ // for a chunked, cursor-resumable fan-out
469
+ // -----------------------------------------------------------------------------
470
+ // A broadcast fans out to every user in the deployment, which can be far
471
+ // more than one job execution should hold open in a single transaction or a
472
+ // single lease window. #323's handler processes users in chunks and, after
473
+ // each chunk commits, writes that chunk's last id to `cursorUserId` so a
474
+ // retried or resumed run picks up exactly where the previous attempt left
475
+ // off instead of restarting from user zero (or, worse, re-sending to
476
+ // everyone already done).
477
+ //
478
+ // `audienceCutoff` IS THE MOST IMPORTANT FIELD ON THIS MODEL, because a
479
+ // cursor alone does not define a stable audience to page through — it only
480
+ // defines a position. Stamped ONCE, when the fan-out starts (`status` moves
481
+ // to `sending`), it is the timestamp every chunk's query filters
482
+ // `users.createdAt` against (`createdAt <= audienceCutoff`), combined with
483
+ // `WHERE id > cursorUserId ORDER BY id ASC` for the page itself. That
484
+ // combination buys two things at once, with no new index on `users`:
485
+ //
486
+ // - A FROZEN AUDIENCE. Without a cutoff, a fan-out that takes long enough
487
+ // for new signups to land mid-run would keep discovering new rows to
488
+ // page through — the query keeps changing under it. A broadcast could
489
+ // then fail to terminate under sustained signups, or (worse) a user who
490
+ // signed up during the run could receive it depending on exactly when
491
+ // their row was inserted relative to the currently-processing chunk.
492
+ // Freezing the boundary at start time makes the set of eligible users
493
+ // fixed the moment sending begins.
494
+ // - A REPLAYABLE "WHO GOT THIS" QUERY. `users.id ASC` is the only
495
+ // ordering that is both STABLE and TOTAL — the primary key never
496
+ // changes for a row and no two rows share a value — so re-running
497
+ // `WHERE createdAt <= audienceCutoff ORDER BY id ASC` after the fact
498
+ // reproduces the exact same page sequence the fan-out actually walked,
499
+ // which `cursorUserId` alone (paging on a column with no recorded
500
+ // boundary) cannot promise once the run has finished. `createdAt` is
501
+ // deliberately NOT used as the paging column for this same reason in
502
+ // reverse: two users can share a timestamp (a bulk import, an unlucky
503
+ // race), and a page boundary drawn on a non-unique column can silently
504
+ // skip or repeat a user at that boundary.
505
+ //
506
+ // This defines two windows precisely, and both are intentional, not edge
507
+ // cases to patch later:
508
+ // - A user created between the broadcast's `createdAt` (composed/
509
+ // scheduled) and `startedAt` (fan-out begins) IS INCLUDED — the rule
510
+ // this model implements is "everyone who exists once sending starts",
511
+ // not "everyone who existed when an admin clicked compose".
512
+ // - A user created between `startedAt` and the run's last chunk is
513
+ // EXCLUDED — they signed up after the audience was frozen, and by
514
+ // definition are not behind the cursor a resumed run would recheck.
515
+ //
516
+ // -----------------------------------------------------------------------------
517
+ // createdById — onDelete: SetNull (and the one-line cost of adding this FK)
518
+ // -----------------------------------------------------------------------------
519
+ // Same reasoning as `AuditEvent.actorUserId` (and `NotificationDelivery
520
+ // .user`, and `Credential.updatedByUser`): this column is an audit trail,
521
+ // not a live reference. Deleting the admin who composed a broadcast must not
522
+ // delete the record of what they announced to every user in the deployment
523
+ // — if anything, that is exactly the record an incident review is most
524
+ // likely to need after the admin account in question no longer exists.
525
+ // Nothing about the broadcast's own meaning depends on the admin row still
526
+ // existing, so the FK is nulled, never cascaded. Worth naming plainly: adding
527
+ // a foreign key to `users` takes a brief `ACCESS SHARE`-class lock on that
528
+ // table while the constraint is validated. At this schema's scale that is
529
+ // not something an operator needs to plan a maintenance window around, but
530
+ // it is a real, if trivial, cost of this migration and not a free action.
531
+ //
532
+ // -----------------------------------------------------------------------------
533
+ // Indexes — exactly the two queries this table is known to serve
534
+ // -----------------------------------------------------------------------------
535
+ // - [status, scheduledFor]: "WHAT IS PENDING" — the scheduler's own query,
536
+ // equality on `status = 'scheduled'` ordered/filtered by `scheduledFor`,
537
+ // for finding due broadcasts to start and upcoming ones to display.
538
+ // - [createdAt desc]: the admin list page's default order, "most recently
539
+ // composed first" — same reasoning as `Job`'s and `Notification`'s own
540
+ // `createdAt(sort: Desc)` indexes, for the same reason: a plain ascending
541
+ // index makes Postgres walk it backwards for the only page anyone loads.
542
+ //
543
+ // -----------------------------------------------------------------------------
544
+ // REJECTED: a `notification_broadcast_recipients` join table
545
+ // -----------------------------------------------------------------------------
546
+ // The exact-tracking alternative — one row per (broadcast, user) pair,
547
+ // recording per-recipient delivery outcome the way `notification_deliveries`
548
+ // does per (event, user) — was considered and rejected for this issue. It is
549
+ // UNBOUNDED GROWTH keyed to the size of the user base on every single
550
+ // broadcast sent (a deployment with 50,000 users sending one broadcast a
551
+ // week is 2.6 million new rows a year for that feature alone), for a
552
+ // marginal gain over what `cursorUserId`/`audienceCutoff` plus the two
553
+ // counters below already give: the queue's own at-least-once contract (see
554
+ // `JobHandler`'s own block comment) already bounds a replay to AT MOST ONE
555
+ // CHUNK — the fan-out resumes from the last committed cursor, so
556
+ // re-processing is inherently limited to users near the boundary, not the
557
+ // whole broadcast. `recipientsTargeted`/`recipientsDispatched` answer "how
558
+ // many were meant to get this, how many did we attempt" at the aggregate
559
+ // level a broadcast's own admin view actually needs; per-recipient
560
+ // per-channel success/failure already lives in `notification_deliveries` —
561
+ // this table must not become a second, weaker copy of that record — and
562
+ // per-recipient READ-RECEIPT tracking is a different, much heavier feature
563
+ // that nothing in #319 asks for.
564
+ // =============================================================================
565
+
566
+ model NotificationBroadcast {
567
+ id String @id @default(uuid()) @db.Uuid
568
+ title String
569
+ body String @db.Text
570
+ link String?
571
+ ctaLabel String? @map("cta_label")
572
+ eventKey String @map("event_key")
573
+ channels String[]
574
+
575
+ status NotificationBroadcastStatus @default(scheduled)
576
+
577
+ scheduledFor DateTime? @map("scheduled_for") @db.Timestamptz
578
+ startedAt DateTime? @map("started_at") @db.Timestamptz
579
+ finishedAt DateTime? @map("finished_at") @db.Timestamptz
580
+ canceledAt DateTime? @map("canceled_at") @db.Timestamptz
581
+
582
+ // The frozen audience boundary and the paging cursor into it — see the
583
+ // block comment above for the full mechanism; the two are read together.
584
+ audienceCutoff DateTime? @map("audience_cutoff") @db.Timestamptz
585
+ cursorUserId String? @map("cursor_user_id") @db.Uuid
586
+
587
+ // A snapshot, not a live count: how many users matched `audienceCutoff`
588
+ // the moment the fan-out started. Nullable because it does not exist
589
+ // until sending begins (a `scheduled`/`draft` broadcast has no audience
590
+ // yet). It can legitimately end up HIGHER than `recipientsDispatched` if
591
+ // users are deactivated mid-flight and the handler skips them — this
592
+ // column means "targeted at send time", never "should have received it".
593
+ recipientsTargeted Int? @map("recipients_targeted")
594
+
595
+ // How many recipients the fan-out has ATTEMPTED so far — incremented per
596
+ // dispatch attempt, not per success. Per-channel success/failure is
597
+ // already recorded, per attempt, in `notification_deliveries`; this
598
+ // column exists only so the admin list/detail view has a cheap aggregate
599
+ // progress number without joining or scanning that table, not as a
600
+ // second, weaker copy of what it already tracks.
601
+ recipientsDispatched Int @default(0) @map("recipients_dispatched")
602
+
603
+ lastError String? @map("last_error") @db.Text
604
+
605
+ createdById String? @map("created_by_id") @db.Uuid
606
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
607
+ updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
608
+
609
+ // The audience's organization (#738, PP-8.5). NULL = every active user of
610
+ // the deployment (a system broadcast, as before #738); set = the ACTIVE
611
+ // members of that organization only. Read by the start and chunk handlers
612
+ // from the row (the job payloads carry only the broadcast id, unchanged), so
613
+ // `audienceWhere(cutoff, targetOrgId)` and the keyset cursor stay one query.
614
+ // No row-level security on this table: a system broadcast belongs to no
615
+ // organization, and RLS would hide it from the job that delivers it; the
616
+ // API authorizes `targetOrgId` instead (`broadcasts:*` may name any org,
617
+ // `org_broadcasts:*` only the caller's active org). CASCADE: an
618
+ // organization's broadcasts have no audience once it is deleted.
619
+ targetOrgId String? @map("target_org_id") @db.Uuid
620
+
621
+ // Relations
622
+ createdBy User? @relation("BroadcastAuthor", fields: [createdById], references: [id], onDelete: SetNull)
623
+ targetOrg Organization? @relation("BroadcastTargetOrg", fields: [targetOrgId], references: [id], onDelete: Cascade)
624
+
625
+ @@index([status, scheduledFor])
626
+ @@index([createdAt(sort: Desc)])
627
+ // A holder of only `org_broadcasts:read` lists its organization's broadcasts.
628
+ @@index([targetOrgId])
629
+ @@map("notification_broadcasts")
630
+ }
631
+
632
+ enum NotificationBroadcastStatus {
633
+ // Not reachable by any route in #319 — see the block comment above for why
634
+ // this dead value exists on purpose. Do not remove it as unused.
635
+ draft
636
+ scheduled
637
+ sending
638
+ sent
639
+ canceled
640
+ failed
641
+ }