diff --git a/prisma/migrations/0298_notification_delivery_identity/migration.sql b/prisma/migrations/0298_notification_delivery_identity/migration.sql new file mode 100644 index 000000000..4e7e27ea2 --- /dev/null +++ b/prisma/migrations/0298_notification_delivery_identity/migration.sql @@ -0,0 +1,77 @@ +-- v1.37.0 — a notification names the health record it concerns and the +-- account whose channels receive it. The former remains the source of health +-- calculations and content; the latter owns delivery preferences and reach. +-- +-- `user_id` remains as a legacy recipient mirror so established self-delivery +-- writers and bounded historical readers stay valid while every row gains the +-- explicit pair. The trigger maps an omitted pair to self-delivery, rejects a +-- partial or inconsistent pair, and refuses a managed profile as recipient. + +ALTER TABLE "push_attempts" + ADD COLUMN "record_user_id" TEXT, + ADD COLUMN "recipient_user_id" TEXT; + +UPDATE "push_attempts" +SET + "record_user_id" = "user_id", + "recipient_user_id" = "user_id"; + +ALTER TABLE "push_attempts" + ALTER COLUMN "record_user_id" SET NOT NULL, + ALTER COLUMN "recipient_user_id" SET NOT NULL; + +ALTER TABLE "push_attempts" + ADD CONSTRAINT "push_attempts_record_user_id_fkey" + FOREIGN KEY ("record_user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE, + ADD CONSTRAINT "push_attempts_recipient_user_id_fkey" + FOREIGN KEY ("recipient_user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE INDEX "push_attempts_recipient_user_id_created_at_desc_idx" + ON "push_attempts" ("recipient_user_id", "created_at" DESC); + +CREATE INDEX "push_attempts_record_user_id_created_at_desc_idx" + ON "push_attempts" ("record_user_id", "created_at" DESC); + +CREATE FUNCTION enforce_push_attempt_attribution() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.record_user_id IS NULL AND NEW.recipient_user_id IS NULL THEN + NEW.record_user_id := NEW.user_id; + NEW.recipient_user_id := NEW.user_id; + ELSIF NEW.record_user_id IS NULL OR NEW.recipient_user_id IS NULL THEN + RAISE EXCEPTION 'push attempt attribution requires both principals'; + ELSIF NEW.user_id <> NEW.recipient_user_id THEN + RAISE EXCEPTION 'push attempt recipient must match user_id'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM "users" + WHERE "id" = NEW.recipient_user_id + AND "managed_profile_at" IS NOT NULL + ) THEN + RAISE EXCEPTION 'managed profile cannot receive notification delivery'; + END IF; + + IF NEW.record_user_id <> NEW.recipient_user_id + AND NOT EXISTS ( + SELECT 1 + FROM "users" + WHERE "id" = NEW.record_user_id + AND "managed_profile_at" IS NOT NULL + ) THEN + RAISE EXCEPTION 'cross-principal delivery requires a managed record'; + END IF; + + RETURN NEW; +END; +$$; + +CREATE TRIGGER push_attempt_attribution_guard +BEFORE INSERT OR UPDATE ON "push_attempts" +FOR EACH ROW +EXECUTE FUNCTION enforce_push_attempt_attribution(); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 457e3a151..a6dfcaab5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -860,7 +860,8 @@ model User { // 20 attempts ordered by recency. Daily prune at 03:35 Europe/Berlin // retires rows older than 90 days (see `handlePushAttemptCleanup` in // `reminder-worker.ts`). - pushAttempts PushAttempt[] + pushAttemptsForRecord PushAttempt[] @relation("PushAttemptRecordUser") + pushAttemptsReceived PushAttempt[] @relation("PushAttemptRecipientUser") // v1.10.3 — per-(user, day) Strain personal-anchor cache (day-total // TRIMP + running EWMA reference) so the nightly Strain engine anchors // the 0–100 map to the user's own training load without re-integrating @@ -5812,17 +5813,26 @@ model ConsentReceipt { // classified `web_push_*` reason; Telegram / NTFY likewise mirror // their sender-side `SendOutcome.reason`. model PushAttempt { - id String @id @default(cuid()) - userId String @map("user_id") - channel String // "APNS" | "WEB_PUSH" | "TELEGRAM" | "NTFY" - eventType String @map("event_type") // dispatcher event-type string - result String // "ok" | "error" | "skipped" - reason String? // sender-side reason on error/skip; null on ok - createdAt DateTime @default(now()) @map("created_at") + id String @id @default(cuid()) + /// Legacy recipient mirror. Migration 0298 preserves established writers + /// while the database trigger requires it to equal `recipientUserId`. + userId String @map("user_id") + /// The health-record subject. This supplies calculation context and content. + recordUserId String? @map("record_user_id") + /// The account whose channel, preferences, and locale receive the delivery. + recipientUserId String? @map("recipient_user_id") + channel String // "APNS" | "WEB_PUSH" | "TELEGRAM" | "NTFY" + eventType String @map("event_type") // dispatcher event-type string + result String // "ok" | "error" | "skipped" + reason String? // sender-side reason on error/skip; null on ok + createdAt DateTime @default(now()) @map("created_at") - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + recordUser User? @relation("PushAttemptRecordUser", fields: [recordUserId], references: [id], onDelete: Cascade) + recipientUser User? @relation("PushAttemptRecipientUser", fields: [recipientUserId], references: [id], onDelete: Cascade) @@index([userId, createdAt(sort: Desc)]) + @@index([recordUserId, createdAt(sort: Desc)]) + @@index([recipientUserId, createdAt(sort: Desc)]) @@map("push_attempts") } diff --git a/src/lib/notifications/senders/push-attempt-record.ts b/src/lib/notifications/senders/push-attempt-record.ts index 78b9bfcac..a0aac6acf 100644 --- a/src/lib/notifications/senders/push-attempt-record.ts +++ b/src/lib/notifications/senders/push-attempt-record.ts @@ -31,22 +31,51 @@ import { getEvent } from "@/lib/logging/context"; * the sender contracts surface without contacting * the upstream provider. */ -export interface PushAttemptRecord { - userId: string; +interface PushAttemptRecordBase { channel: "APNS" | "WEB_PUSH" | "TELEGRAM" | "NTFY" | "WEBHOOK" | "EMAIL"; eventType: string; result: "ok" | "error" | "skipped"; reason?: string | null; } +/** Existing self-delivery callers map the record and recipient to one account. */ +interface SelfPushAttemptRecord extends PushAttemptRecordBase { + userId: string; + recordUserId?: never; + recipientUserId?: never; +} + +/** Managed delivery always supplies both principals without inference. */ +interface AttributedPushAttemptRecord extends PushAttemptRecordBase { + userId?: never; + recordUserId: string; + recipientUserId: string; +} + +export type PushAttemptRecord = + SelfPushAttemptRecord | AttributedPushAttemptRecord; + export function recordPushAttempt(record: PushAttemptRecord): void { + const recordUserId = "userId" in record ? record.userId : record.recordUserId; + const recipientUserId = + "userId" in record ? record.userId : record.recipientUserId; + + if (!recordUserId || !recipientUserId) { + getEvent()?.addWarning( + "push_attempt_ledger_write_failed invalid_attribution", + ); + return; + } + // The `void` here is the doubly-explicit form: we want neither the // caller nor a linter to wait on this promise. The `.catch` traps // a rejected promise without surfacing it to the caller. void prisma.pushAttempt .create({ data: { - userId: record.userId, + userId: recipientUserId, + recordUserId, + recipientUserId, channel: record.channel, eventType: record.eventType, result: record.result, diff --git a/tests/integration/notification-attribution.test.ts b/tests/integration/notification-attribution.test.ts index e7a08e801..b522735a9 100644 --- a/tests/integration/notification-attribution.test.ts +++ b/tests/integration/notification-attribution.test.ts @@ -5,12 +5,13 @@ import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-reco let sequence = 0; -async function createUser(label: string) { +async function createUser(label: string, managed = false) { const suffix = sequence++; return getPrismaClient().user.create({ data: { username: `${label}-${suffix}`, email: `${label}-${suffix}@example.test`, + managedProfileAt: managed ? new Date() : null, }, }); } @@ -21,7 +22,7 @@ beforeEach(async () => { describe("notification attribution (real Postgres)", () => { it("persistence retains distinct record and recipient principals", async () => { - const recordUser = await createUser("record"); + const recordUser = await createUser("record", true); const recipientUser = await createUser("recipient"); recordPushAttempt({