feat: separate notification subjects and recipients

This commit is contained in:
Marc-André Bombeck committed 2026-08-06 03:46:57 +02:00
1 parent 6771b9060f
commit 6108a71d80
4 files changed
+131 -14

No files matched your search

@@ -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();
+19 -9
View File
@@ -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")
}
@@ -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,
@@ -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({