@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.
- package/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/README.md +166 -0
- package/dist/ask/index.d.ts +6 -0
- package/dist/ask/index.js +28 -0
- package/dist/ask/question-schema.d.ts +121 -0
- package/dist/ask/question-schema.js +167 -0
- package/dist/ask/report.d.ts +88 -0
- package/dist/ask/report.js +170 -0
- package/dist/ask/scorer.d.ts +104 -0
- package/dist/ask/scorer.js +164 -0
- package/dist/ask-run/ask-command.d.ts +62 -0
- package/dist/ask-run/ask-command.js +254 -0
- package/dist/ask-run/host/app.d.ts +65 -0
- package/dist/ask-run/host/app.js +146 -0
- package/dist/ask-run/host/db.d.ts +38 -0
- package/dist/ask-run/host/db.js +126 -0
- package/dist/ask-run/host/ports.d.ts +35 -0
- package/dist/ask-run/host/ports.js +113 -0
- package/dist/ask-run/index.d.ts +13 -0
- package/dist/ask-run/index.js +35 -0
- package/dist/ask-run/replay.d.ts +128 -0
- package/dist/ask-run/replay.js +212 -0
- package/dist/ask-run/run-ask-eval.d.ts +125 -0
- package/dist/ask-run/run-ask-eval.js +193 -0
- package/dist/ask-run/seed-golden-graph.d.ts +64 -0
- package/dist/ask-run/seed-golden-graph.js +495 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.js +12 -0
- package/dist/cli/kg-command.d.ts +66 -0
- package/dist/cli/kg-command.js +238 -0
- package/dist/cli/main.d.ts +18 -0
- package/dist/cli/main.js +69 -0
- package/dist/fixtures/domains.d.ts +16 -0
- package/dist/fixtures/domains.js +33 -0
- package/dist/fixtures/fixture-schema.d.ts +323 -0
- package/dist/fixtures/fixture-schema.js +199 -0
- package/dist/fixtures/index.d.ts +16 -0
- package/dist/fixtures/index.js +40 -0
- package/dist/fixtures/load.d.ts +73 -0
- package/dist/fixtures/load.js +137 -0
- package/dist/fixtures/questions.d.ts +15 -0
- package/dist/fixtures/questions.js +26 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +36 -0
- package/dist/kg/gold-runner.d.ts +13 -0
- package/dist/kg/gold-runner.js +57 -0
- package/dist/kg/index.d.ts +17 -0
- package/dist/kg/index.js +65 -0
- package/dist/kg/internals.d.ts +7 -0
- package/dist/kg/internals.js +17 -0
- package/dist/kg/prediction-schema.d.ts +245 -0
- package/dist/kg/prediction-schema.js +102 -0
- package/dist/kg/predictions.d.ts +11 -0
- package/dist/kg/predictions.js +49 -0
- package/dist/kg/report.d.ts +84 -0
- package/dist/kg/report.js +125 -0
- package/dist/kg/run-kg-eval.d.ts +77 -0
- package/dist/kg/run-kg-eval.js +137 -0
- package/dist/kg/runner.d.ts +72 -0
- package/dist/kg/runner.js +51 -0
- package/dist/kg/runners/extract-resolve-runner.d.ts +40 -0
- package/dist/kg/runners/extract-resolve-runner.js +199 -0
- package/dist/kg/runners/extract-runner.d.ts +65 -0
- package/dist/kg/runners/extract-runner.js +243 -0
- package/dist/kg/scorer.d.ts +226 -0
- package/dist/kg/scorer.js +463 -0
- package/dist/kg/targets.d.ts +17 -0
- package/dist/kg/targets.js +16 -0
- package/dist/kg/text.d.ts +45 -0
- package/dist/kg/text.js +100 -0
- package/dist/live/adapters.d.ts +16 -0
- package/dist/live/adapters.js +58 -0
- package/dist/live/env.d.ts +39 -0
- package/dist/live/env.js +56 -0
- package/dist/live/index.d.ts +6 -0
- package/dist/live/index.js +16 -0
- package/dist/live/live-ai.d.ts +104 -0
- package/dist/live/live-ai.js +157 -0
- package/dist/parity/constants.d.ts +79 -0
- package/dist/parity/constants.js +104 -0
- package/dist/parity/index.d.ts +20 -0
- package/dist/parity/index.js +38 -0
- package/dist/parity/internals.d.ts +8 -0
- package/dist/parity/internals.js +16 -0
- package/dist/parity/scenario.d.ts +45 -0
- package/dist/parity/scenario.js +245 -0
- package/dist/parity/script.d.ts +44 -0
- package/dist/parity/script.js +136 -0
- package/dist/parity/snapshot.d.ts +43 -0
- package/dist/parity/snapshot.js +71 -0
- package/dist/real-data/index.d.ts +2 -0
- package/dist/real-data/index.js +12 -0
- package/dist/real-data/real-data.d.ts +76 -0
- package/dist/real-data/real-data.js +156 -0
- package/fixtures/kg-golden/README.md +136 -0
- package/fixtures/kg-golden/ask-questions.json +458 -0
- package/fixtures/kg-golden/meetings/m01-northwind-pilot-kickoff.json +792 -0
- package/fixtures/kg-golden/meetings/m02-halden-migration-planning.json +814 -0
- package/fixtures/kg-golden/meetings/m03-larkspur-weekly-sync.json +601 -0
- package/fixtures/kg-golden/meetings/m04-corvid-intake-discovery.json +690 -0
- package/fixtures/kg-golden/meetings/m05-hosting-vendor-selection.json +631 -0
- package/fixtures/kg-golden/meetings/m06-northwind-floor-data-review.json +649 -0
- package/fixtures/kg-golden/meetings/m07-gullwing-intro-notes.json +428 -0
- package/fixtures/kg-golden/meetings/m08-halden-migration-checkpoint.json +672 -0
- package/fixtures/kg-golden/meetings/m09-northwind-platform-org-update.json +711 -0
- package/fixtures/kg-golden/meetings/m10-corvid-prototype-review.json +623 -0
- package/fixtures/kg-golden/meetings/m11-solvane-grid-dashboard-scoping.json +689 -0
- package/fixtures/kg-golden/meetings/m12-northwind-pilot-replan.json +681 -0
- package/fixtures/kg-golden/meetings/m13-halden-cutover-go-no-go.json +721 -0
- package/fixtures/kg-golden/meetings/m14-gullwing-discovery-readout.json +719 -0
- package/fixtures/kg-golden/meetings/m15-halden-handover-ravi-leaving.json +716 -0
- package/fixtures/kg-golden/meetings/m16-varden-claims-portal-intro.json +704 -0
- package/fixtures/kg-golden/meetings/m17-corvid-usability-debrief.json +572 -0
- package/fixtures/kg-golden/meetings/m18-hosting-decision-revisited.json +655 -0
- package/fixtures/kg-golden/meetings/m19-solvane-integration-kickoff.json +726 -0
- package/fixtures/kg-golden/meetings/m20-northwind-go-live-retro.json +673 -0
- package/fixtures/kg-golden/meetings/m21-quarterly-account-review.json +449 -0
- package/fixtures/kg-golden/meetings/m22-halden-intro-call-2020.json +558 -0
- package/fixtures/kg-golden/meetings/m23-gullwing-model-review.json +702 -0
- package/fixtures/kg-golden/meetings/m24-varden-discovery-midpoint.json +643 -0
- package/fixtures/kg-golden/meetings/m25-corvid-rollout-planning.json +642 -0
- package/fixtures/kg-golden/meetings/m26-larkspur-engineering-prep.json +695 -0
- package/fixtures/kg-golden/meetings/m27-solvane-phase-one-demo.json +649 -0
- package/fixtures/kg-golden/meetings/m28-northwind-building-5-scoping.json +715 -0
- package/fixtures/kg-golden/meetings/m29-halden-southern-cutover-review.json +752 -0
- package/fixtures/kg-golden/meetings/m30-varden-portal-build-kickoff.json +697 -0
- package/fixtures/kg-golden/meetings/m31-gullwing-rollout-renewal.json +681 -0
- package/fixtures/kg-golden/meetings/m32-brambleway-weekly-sync.json +472 -0
- package/fixtures/kg-golden/meetings/m33-nina-oliver-one-on-one.json +342 -0
- package/fixtures/kg-golden/meetings/m34-coffee-with-dana.json +249 -0
- package/fixtures/kg-golden/meetings/m35-brambleway-june-sync.json +553 -0
- package/fixtures/kg-golden/meetings/m36-copenhagen-conference-planning.json +296 -0
- package/host/generated/client.d.ts +1 -0
- package/host/generated/client.js +5 -0
- package/host/generated/default.d.ts +1 -0
- package/host/generated/default.js +5 -0
- package/host/generated/edge.d.ts +1 -0
- package/host/generated/edge.js +1658 -0
- package/host/generated/index-browser.js +1684 -0
- package/host/generated/index.d.ts +192335 -0
- package/host/generated/index.js +1658 -0
- package/host/generated/package.json +139 -0
- package/host/generated/query_compiler_fast_bg.js +2 -0
- package/host/generated/query_compiler_fast_bg.wasm +0 -0
- package/host/generated/query_compiler_fast_bg.wasm-base64.js +2 -0
- package/host/generated/runtime/client.d.ts +3433 -0
- package/host/generated/runtime/client.js +110 -0
- package/host/generated/runtime/index-browser.d.ts +90 -0
- package/host/generated/runtime/index-browser.js +6 -0
- package/host/generated/runtime/wasm-compiler-edge.js +100 -0
- package/host/generated/schema.prisma +6312 -0
- package/host/generated/wasm-edge-light-loader.mjs +5 -0
- package/host/generated/wasm-worker-loader.mjs +5 -0
- package/host/prisma/fragments/kvox.ask.prisma +126 -0
- package/host/prisma/fragments/kvox.graph.prisma +1073 -0
- package/host/prisma/fragments/kvox.notes.prisma +787 -0
- package/host/prisma/fragments/kvox.search.prisma +211 -0
- package/host/prisma/fragments/kvox.transcripts.prisma +790 -0
- package/host/prisma/kvox.lock +101 -0
- package/host/prisma/migrations/20240101000000_initial/migration.sql +297 -0
- package/host/prisma/migrations/20240101000001_add_personal_access_tokens/migration.sql +31 -0
- package/host/prisma/migrations/20240101000002_add_credentials/migration.sql +20 -0
- package/host/prisma/migrations/20240101000003_add_notification_deliveries/migration.sql +27 -0
- package/host/prisma/migrations/20240101000004_drop_stale_uuid_defaults/migration.sql +35 -0
- package/host/prisma/migrations/20240101000005_add_notifications/migration.sql +22 -0
- package/host/prisma/migrations/20240101000006_add_push_subscriptions/migration.sql +25 -0
- package/host/prisma/migrations/20240101000007_add_jobs/migration.sql +110 -0
- package/host/prisma/migrations/20240101000008_add_worker_nodes/migration.sql +90 -0
- package/host/prisma/migrations/20240101000009_add_database_backup_runs/migration.sql +104 -0
- package/host/prisma/migrations/20240101000010_add_notification_broadcasts/migration.sql +57 -0
- package/host/prisma/migrations/20240101000011_add_backup_run_job_link/migration.sql +87 -0
- package/host/prisma/migrations/20240101000012_add_job_node_secrets/migration.sql +61 -0
- package/host/prisma/migrations/20240101000013_add_backup_run_pg_dump_version/migration.sql +26 -0
- package/host/prisma/migrations/20240101000014_add_job_claim_token/migration.sql +92 -0
- package/host/prisma/migrations/20240101000015_add_ai_platform/migration.sql +108 -0
- package/host/prisma/migrations/20240101000016_revoke_viewer_ai_use/migration.sql +32 -0
- package/host/prisma/migrations/20240101000017_add_user_credentials/migration.sql +29 -0
- package/host/prisma/migrations/20240101000018_add_device_session_credential_link/migration.sql +52 -0
- package/host/prisma/migrations/20240101000019_add_worker_node_vitals/migration.sql +3 -0
- package/host/prisma/migrations/20240101000020_add_job_trace_context/migration.sql +6 -0
- package/host/prisma/migrations/20240101000021_add_retention_created_at_indexes/migration.sql +13 -0
- package/host/prisma/migrations/20240101000022_add_organizations/migration.sql +142 -0
- package/host/prisma/migrations/20240101000023_split_system_org_roles/migration.sql +131 -0
- package/host/prisma/migrations/20240101000024_org_scoped_rls/migration.sql +215 -0
- package/host/prisma/migrations/20240101000025_add_groups/migration.sql +165 -0
- package/host/prisma/migrations/20240101000026_add_grants/migration.sql +117 -0
- package/host/prisma/migrations/20240101000027_add_org_settings/migration.sql +49 -0
- package/host/prisma/migrations/20240101000028_add_org_credentials/migration.sql +54 -0
- package/host/prisma/migrations/20240101000029_add_jobs_org_id/migration.sql +18 -0
- package/host/prisma/migrations/20240101000030_add_jobs_org_id_status_index/migration.sql +6 -0
- package/host/prisma/migrations/20240101000031_add_broadcast_target_org/migration.sql +21 -0
- package/host/prisma/migrations/20240101000032_add_android_app/migration.sql +60 -0
- package/host/prisma/migrations/20240101000033_open_job_reason/migration.sql +16 -0
- package/host/prisma/migrations/20240101000034_ai_usage_feature/migration.sql +37 -0
- package/host/prisma/migrations/20240101000035_add_app_metadata/migration.sql +49 -0
- package/host/prisma/migrations/20240101000036_open_sharing_vocabularies/migration.sql +97 -0
- package/host/prisma/migrations/20260914130000_kvox_add_transcripts/migration.sql +322 -0
- package/host/prisma/migrations/20260914140000_kvox_add_notes/migration.sql +311 -0
- package/host/prisma/migrations/20260915120000_kvox_add_search_vectors/migration.sql +150 -0
- package/host/prisma/migrations/20260915130000_kvox_add_search_embeddings/migration.sql +362 -0
- package/host/prisma/migrations/20260915210000_kvox_add_note_title_source/migration.sql +30 -0
- package/host/prisma/migrations/20260923120000_kvox_add_note_generation_context_snapshot/migration.sql +24 -0
- package/host/prisma/migrations/20260923130000_kvox_add_user_hidden_note_templates/migration.sql +35 -0
- package/host/prisma/migrations/20260923140000_kvox_add_transcript_speaker_identities/migration.sql +6 -0
- package/host/prisma/migrations/20260923150000_kvox_transcript_name_checks/migration.sql +95 -0
- package/host/prisma/migrations/20260923160000_kvox_add_note_body_format/migration.sql +27 -0
- package/host/prisma/migrations/20260923170000_kvox_add_note_version_body_format/migration.sql +23 -0
- package/host/prisma/migrations/20260926012548_kvox_add_knowledge_graph/migration.sql +680 -0
- package/host/prisma/migrations/20260926030000_kvox_fix_kg_relations_valid_precision_chk/migration.sql +29 -0
- package/host/prisma/migrations/20260926030100_kvox_add_transcript_recorded_at/migration.sql +9 -0
- package/host/prisma/migrations/20260926030200_kvox_kg_evidence_invariant/migration.sql +60 -0
- package/host/prisma/migrations/20260926040000_kvox_add_kg_graph_layouts/migration.sql +30 -0
- package/host/prisma/migrations/20260926050000_kvox_add_ask_conversations/migration.sql +99 -0
- package/host/prisma/migrations/20260926231512_kvox_add_kg_exports/migration.sql +52 -0
- package/host/prisma/migrations/20261016120000_kvox_transcripts_org_id/migration.sql +49 -0
- package/host/prisma/platform.lock +319 -0
- package/host/prisma/schema/app.kvox.ask.prisma +110 -0
- package/host/prisma/schema/app.kvox.graph.prisma +1030 -0
- package/host/prisma/schema/app.kvox.notes.prisma +746 -0
- package/host/prisma/schema/app.kvox.search.prisma +208 -0
- package/host/prisma/schema/app.kvox.transcripts.prisma +726 -0
- package/host/prisma/schema/platform.ai.prisma +419 -0
- package/host/prisma/schema/platform.android-app.prisma +46 -0
- package/host/prisma/schema/platform.base.prisma +15 -0
- package/host/prisma/schema/platform.credentials.prisma +156 -0
- package/host/prisma/schema/platform.db-backup.prisma +359 -0
- package/host/prisma/schema/platform.identity.prisma +638 -0
- package/host/prisma/schema/platform.jobs.prisma +824 -0
- package/host/prisma/schema/platform.notifications.prisma +641 -0
- package/host/prisma/schema/platform.settings.prisma +53 -0
- package/host/prisma/schema/platform.sharing.prisma +208 -0
- package/host/prisma/schema/platform.storage.prisma +118 -0
- package/package.json +112 -1
- package/parity/requests.ask.json +2681 -0
- package/parity/requests.m01.json +1554 -0
- package/parity/requests.m02.json +1554 -0
- package/parity/requests.m03.json +1554 -0
- package/parity/requests.m04.json +1554 -0
- package/parity/requests.m05.json +1554 -0
- package/parity/requests.m06.json +1554 -0
- package/parity/requests.m07.json +1554 -0
- package/parity/requests.m08.json +1554 -0
- package/parity/requests.m09.json +1554 -0
- 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
|
+
}
|