mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat: separate notification subjects and recipients
This commit is contained in:
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
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user