mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
chore(merge): back-merge main into develop after PR #190 (v0.5.4 APNs)
Resolves four trivially-additive conflicts at file tail in
messages/{es,fr,it,pl}.json (new `moodReminders` namespace from
PR #190) and in src/lib/jobs/reminder-worker.ts (new MOOD_REMINDER
queue + cron constants from PR #190). The v1.4.38 docs commits on
develop did not touch the conflict regions; all four conflicts
resolve cleanly by taking the merge side.
Keeps develop and main aligned per the branch model: PR #190 was
authored against main directly to coordinate with the iOS v0.5.4
release; back-merge here so develop carries the same baseline for
the next feature cycle.
This commit is contained in:
commit
07574480c9
17 files changed
+1311
-1065
No files matched your search
@@ -188,7 +188,7 @@ jobs:
|
||||
# arm64 layer never poisons an amd64 build).
|
||||
- name: Build & push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
|
||||
@@ -2987,5 +2987,9 @@
|
||||
"buttonSnoozeOneHour": "🕐 1h",
|
||||
"buttonSnoozeThreeHours": "🕐 3h",
|
||||
"buttonSkip": "⏭ Überspringen"
|
||||
},
|
||||
"moodReminders": {
|
||||
"dailyTitle": "Stimmung erfassen",
|
||||
"dailyBody": "Wie geht es dir heute?"
|
||||
}
|
||||
}
|
||||
@@ -2987,5 +2987,9 @@
|
||||
"buttonSnoozeOneHour": "🕐 1h",
|
||||
"buttonSnoozeThreeHours": "🕐 3h",
|
||||
"buttonSkip": "⏭ Skip"
|
||||
},
|
||||
"moodReminders": {
|
||||
"dailyTitle": "Log your mood",
|
||||
"dailyBody": "How are you feeling today?"
|
||||
}
|
||||
}
|
||||
@@ -2987,5 +2987,9 @@
|
||||
"buttonSnoozeOneHour": "🕐 1h",
|
||||
"buttonSnoozeThreeHours": "🕐 3h",
|
||||
"buttonSkip": "⏭ Omitir"
|
||||
},
|
||||
"moodReminders": {
|
||||
"dailyTitle": "Registrar el ánimo",
|
||||
"dailyBody": "¿Cómo te sientes hoy?"
|
||||
}
|
||||
}
|
||||
@@ -2987,5 +2987,9 @@
|
||||
"buttonSnoozeOneHour": "🕐 1h",
|
||||
"buttonSnoozeThreeHours": "🕐 3h",
|
||||
"buttonSkip": "⏭ Ignorer"
|
||||
},
|
||||
"moodReminders": {
|
||||
"dailyTitle": "Enregistrer votre humeur",
|
||||
"dailyBody": "Comment vous sentez-vous aujourdhui ?"
|
||||
}
|
||||
}
|
||||
@@ -2987,5 +2987,9 @@
|
||||
"buttonSnoozeOneHour": "🕐 1h",
|
||||
"buttonSnoozeThreeHours": "🕐 3h",
|
||||
"buttonSkip": "⏭ Salta"
|
||||
},
|
||||
"moodReminders": {
|
||||
"dailyTitle": "Registra il tuo umore",
|
||||
"dailyBody": "Come ti senti oggi?"
|
||||
}
|
||||
}
|
||||
@@ -2987,5 +2987,9 @@
|
||||
"buttonSnoozeOneHour": "🕐 1h",
|
||||
"buttonSnoozeThreeHours": "🕐 3h",
|
||||
"buttonSkip": "⏭ Pomiń"
|
||||
},
|
||||
"moodReminders": {
|
||||
"dailyTitle": "Zapisz nastrój",
|
||||
"dailyBody": "Jak się dzisiaj czujesz?"
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -47,7 +47,7 @@
|
||||
"openapi:check": "npx tsx scripts/check-openapi.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1045.0",
|
||||
"@aws-sdk/client-s3": "^3.1048.0",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@parse/node-apn": "^8.1.0",
|
||||
@@ -55,7 +55,7 @@
|
||||
"@prisma/client": "^7.8.0",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/server": "^13.3.0",
|
||||
"@tanstack/react-query": "^5.100.9",
|
||||
"@tanstack/react-query": "^5.100.10",
|
||||
"@types/sax": "^1.2.7",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -85,7 +85,7 @@
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.11.3",
|
||||
"@next/bundle-analyzer": "^16.2.6",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testcontainers/postgresql": "^11.14.0",
|
||||
"@types/node": "^25",
|
||||
|
||||
Generated
+375
-1051
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,39 @@
|
||||
-- v0.5.4 ios-coord — daily mood reminder.
|
||||
--
|
||||
-- Two additions:
|
||||
-- 1. `users.mood_reminder_enabled` — per-user opt-in flag. Default
|
||||
-- `false` because mood capture is an emotionally-loaded surface;
|
||||
-- we never want a fresh-install user to receive a 22:00 nudge
|
||||
-- without first toggling the preference in Settings.
|
||||
-- 2. `mood_reminder_dispatches` — per-(user, local date) ledger
|
||||
-- enforcing one push per day. Unique constraint doubles as the
|
||||
-- idempotency anchor for parallel workers + cron re-ticks inside
|
||||
-- the 22:00 window (cron fires every 15 min).
|
||||
--
|
||||
-- Both additions are additive — existing rows stay on the
|
||||
-- "opt-out-of-everything" path byte-identical to v1.4.38 behaviour.
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN IF NOT EXISTS "mood_reminder_enabled" BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "mood_reminder_dispatches" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"date" TEXT NOT NULL,
|
||||
"dispatched_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "mood_reminder_dispatches_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "mood_reminder_dispatches_user_id_date_key"
|
||||
ON "mood_reminder_dispatches"("user_id", "date");
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "mood_reminder_dispatches_user_id_dispatched_at_idx"
|
||||
ON "mood_reminder_dispatches"("user_id", "dispatched_at");
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "mood_reminder_dispatches"
|
||||
ADD CONSTRAINT "mood_reminder_dispatches_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
@@ -198,6 +198,17 @@ model User {
|
||||
// a full refresh on the next pair".
|
||||
lastSyncedAt DateTime? @map("last_synced_at")
|
||||
|
||||
// v0.5.4 ios-coord — daily mood-reminder opt-in.
|
||||
//
|
||||
// When `true` the reminder worker dispatches a `MOOD_REMINDER`
|
||||
// notification at 22:00 in the user's local timezone if no
|
||||
// `MoodEntry` row exists for the local date yet. Default `false`
|
||||
// because mood capture is an emotional surface — the user has to
|
||||
// explicitly opt in from Settings → Notifications before the
|
||||
// server starts nudging them. The dedup is handled by the
|
||||
// `MoodReminderDispatch` table (one row per user + local date).
|
||||
moodReminderEnabled Boolean @default(false) @map("mood_reminder_enabled")
|
||||
|
||||
// v1.4.25 W6c — Doctor-report section toggles.
|
||||
// Shape:
|
||||
// { "bp": boolean,
|
||||
@@ -267,6 +278,12 @@ model User {
|
||||
// granularity from the measurement write hooks; WEEK/MONTH/YEAR
|
||||
// are enqueued through pg-boss for the worker to fold.
|
||||
measurementRollups MeasurementRollup[]
|
||||
// v0.5.4 ios-coord — per-day dedup ledger for MOOD_REMINDER pushes.
|
||||
// One row per (user, local date) the worker has nudged. Prevents
|
||||
// a second push when the cron rechecks the same hour later in the
|
||||
// 22:00 window (the cron fires every 15 min — without the ledger
|
||||
// a user could see three pushes back-to-back).
|
||||
moodReminderDispatches MoodReminderDispatch[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -1173,6 +1190,27 @@ model MoodEntry {
|
||||
@@map("mood_entries")
|
||||
}
|
||||
|
||||
// ─── Mood Reminder Dispatch Ledger (v0.5.4 ios-coord) ─────
|
||||
//
|
||||
// Idempotency guard for the daily 22:00 MOOD_REMINDER push. The
|
||||
// reminder cron fires every 15 min — when it lands inside the 22:00
|
||||
// window the handler looks up `(userId, date)` here and bails if a row
|
||||
// already exists, so the user sees exactly one nudge per local day.
|
||||
// `date` is a YYYY-MM-DD string anchored to the user's timezone at
|
||||
// dispatch time (same encoding convention as MoodEntry.date).
|
||||
model MoodReminderDispatch {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
date String // YYYY-MM-DD anchored to user.timezone
|
||||
dispatchedAt DateTime @default(now()) @map("dispatched_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, date])
|
||||
@@index([userId, dispatchedAt])
|
||||
@@map("mood_reminder_dispatches")
|
||||
}
|
||||
|
||||
// ─── Integration Status (v1.4.15) ─────────────────────────
|
||||
//
|
||||
// Per-(user, integration) row that captures the most recent sync attempt
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
/**
|
||||
* v0.5.4 ios-coord — mood-reminder dispatcher unit tests.
|
||||
*
|
||||
* Pins the contract that motivated the v0.5.4 server-side patch:
|
||||
* - Opt-in gating: `moodReminderEnabled = false` ⇒ never dispatch.
|
||||
* - Window gating: only fires inside the 22:00 local-time hour.
|
||||
* - Logged-today skip: a `MoodEntry` row for the local date short-
|
||||
* circuits the dispatch.
|
||||
* - Idempotency: a `MoodReminderDispatch` row blocks a second push
|
||||
* for the same (user, date) — even when the cron re-ticks inside
|
||||
* the same 22:00 window.
|
||||
* - Locale: title + body come from `messages/{de,en}.json` and fall
|
||||
* back to the app default when the user's locale is null/unknown.
|
||||
*
|
||||
* The tests stub the Prisma surface manually to keep this file free of
|
||||
* a testcontainer boot.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import {
|
||||
buildMoodReminderPayload,
|
||||
evaluateMoodReminderWindow,
|
||||
runMoodReminderTick,
|
||||
MOOD_REMINDER_LOCAL_HOUR,
|
||||
} from "../mood-reminder";
|
||||
import type { NotificationPayload } from "@/lib/notifications/types";
|
||||
|
||||
type DispatchFn = (payload: NotificationPayload) => Promise<void>;
|
||||
|
||||
interface FakePrismaState {
|
||||
candidates: Array<{
|
||||
id: string;
|
||||
timezone: string;
|
||||
locale: string | null;
|
||||
moodReminderEnabled: boolean;
|
||||
}>;
|
||||
moodEntries: Array<{ userId: string; date: string }>;
|
||||
dispatches: Array<{ userId: string; date: string }>;
|
||||
/** Force the next `moodReminderDispatch.create` to throw P2002. */
|
||||
raceUserIds: Set<string>;
|
||||
}
|
||||
|
||||
function makePrisma(state: FakePrismaState) {
|
||||
return {
|
||||
user: {
|
||||
findMany: vi.fn(
|
||||
async ({ where }: { where: { moodReminderEnabled: boolean } }) => {
|
||||
return state.candidates.filter(
|
||||
(c) => c.moodReminderEnabled === where.moodReminderEnabled,
|
||||
);
|
||||
},
|
||||
),
|
||||
},
|
||||
moodEntry: {
|
||||
findFirst: vi.fn(
|
||||
async ({ where }: { where: { userId: string; date: string } }) => {
|
||||
return (
|
||||
state.moodEntries.find(
|
||||
(m) => m.userId === where.userId && m.date === where.date,
|
||||
) ?? null
|
||||
);
|
||||
},
|
||||
),
|
||||
},
|
||||
moodReminderDispatch: {
|
||||
findUnique: vi.fn(
|
||||
async ({
|
||||
where,
|
||||
}: {
|
||||
where: { userId_date: { userId: string; date: string } };
|
||||
}) => {
|
||||
return (
|
||||
state.dispatches.find(
|
||||
(d) =>
|
||||
d.userId === where.userId_date.userId &&
|
||||
d.date === where.userId_date.date,
|
||||
) ?? null
|
||||
);
|
||||
},
|
||||
),
|
||||
create: vi.fn(
|
||||
async ({ data }: { data: { userId: string; date: string } }) => {
|
||||
if (state.raceUserIds.has(data.userId)) {
|
||||
// Mirrors the Prisma `P2002` shape closely enough that the
|
||||
// handler's substring match (`includes("P2002")`) catches it.
|
||||
throw new Error(
|
||||
"Invalid `prisma.moodReminderDispatch.create()` invocation: " +
|
||||
"Unique constraint failed on the fields: (`user_id`,`date`). " +
|
||||
"code: P2002",
|
||||
);
|
||||
}
|
||||
state.dispatches.push(data);
|
||||
return { id: `dispatch-${state.dispatches.length}`, ...data };
|
||||
},
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("evaluateMoodReminderWindow", () => {
|
||||
it("returns fire=false when the user is opted out", () => {
|
||||
const at22 = new Date("2026-05-17T20:00:00Z"); // 22:00 Europe/Berlin
|
||||
const r = evaluateMoodReminderWindow(
|
||||
{ timezone: "Europe/Berlin", moodReminderEnabled: false },
|
||||
at22,
|
||||
);
|
||||
expect(r.fire).toBe(false);
|
||||
expect(r.localDate).toBeNull();
|
||||
});
|
||||
|
||||
it("fires inside the 22:00 hour and returns the local date string", () => {
|
||||
const at22 = new Date("2026-05-17T20:00:00Z"); // 22:00 Europe/Berlin (CEST)
|
||||
const r = evaluateMoodReminderWindow(
|
||||
{ timezone: "Europe/Berlin", moodReminderEnabled: true },
|
||||
at22,
|
||||
);
|
||||
expect(r.fire).toBe(true);
|
||||
expect(r.localDate).toBe("2026-05-17");
|
||||
expect(r.localHour).toBe(MOOD_REMINDER_LOCAL_HOUR);
|
||||
});
|
||||
|
||||
it("does NOT fire at 21:59 local time (off-by-one guard)", () => {
|
||||
const at2159 = new Date("2026-05-17T19:59:00Z"); // 21:59 Europe/Berlin
|
||||
const r = evaluateMoodReminderWindow(
|
||||
{ timezone: "Europe/Berlin", moodReminderEnabled: true },
|
||||
at2159,
|
||||
);
|
||||
expect(r.fire).toBe(false);
|
||||
expect(r.localHour).toBe(21);
|
||||
});
|
||||
|
||||
it("does NOT fire at 23:00 local time (window end)", () => {
|
||||
const at23 = new Date("2026-05-17T21:00:00Z"); // 23:00 Europe/Berlin
|
||||
const r = evaluateMoodReminderWindow(
|
||||
{ timezone: "Europe/Berlin", moodReminderEnabled: true },
|
||||
at23,
|
||||
);
|
||||
expect(r.fire).toBe(false);
|
||||
expect(r.localHour).toBe(23);
|
||||
});
|
||||
|
||||
it("respects a non-Berlin timezone (America/New_York is 6h behind in DST)", () => {
|
||||
// 02:00 UTC on 2026-05-18 = 22:00 EDT on 2026-05-17 (UTC-4 during DST).
|
||||
const utc = new Date("2026-05-18T02:00:00Z");
|
||||
const r = evaluateMoodReminderWindow(
|
||||
{ timezone: "America/New_York", moodReminderEnabled: true },
|
||||
utc,
|
||||
);
|
||||
expect(r.fire).toBe(true);
|
||||
expect(r.localDate).toBe("2026-05-17");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildMoodReminderPayload", () => {
|
||||
it("returns German strings for locale=de", () => {
|
||||
const p = buildMoodReminderPayload("de");
|
||||
expect(p.title).toBe("Stimmung erfassen");
|
||||
expect(p.body).toBe("Wie geht es dir heute?");
|
||||
});
|
||||
|
||||
it("returns English strings for locale=en", () => {
|
||||
const p = buildMoodReminderPayload("en");
|
||||
expect(p.title).toBe("Log your mood");
|
||||
expect(p.body).toBe("How are you feeling today?");
|
||||
});
|
||||
|
||||
it("falls back to the app default for null / unknown locales", () => {
|
||||
const p1 = buildMoodReminderPayload(null);
|
||||
const p2 = buildMoodReminderPayload("zz-ZZ");
|
||||
// Both should resolve to the same content (the app default).
|
||||
expect(p1.title).toBe(p2.title);
|
||||
expect(p1.title.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runMoodReminderTick", () => {
|
||||
// 22:00 Europe/Berlin (CEST) ⇒ 20:00 UTC.
|
||||
const inWindow = new Date("2026-05-17T20:00:00Z");
|
||||
// 21:00 Europe/Berlin (CEST) ⇒ 19:00 UTC.
|
||||
const outOfWindow = new Date("2026-05-17T19:00:00Z");
|
||||
|
||||
it("dispatches MOOD_REMINDER for opted-in users in the 22:00 window", async () => {
|
||||
const state: FakePrismaState = {
|
||||
candidates: [
|
||||
{
|
||||
id: "u-1",
|
||||
timezone: "Europe/Berlin",
|
||||
locale: "de",
|
||||
moodReminderEnabled: true,
|
||||
},
|
||||
],
|
||||
moodEntries: [],
|
||||
dispatches: [],
|
||||
raceUserIds: new Set(),
|
||||
};
|
||||
const prisma = makePrisma(state);
|
||||
const dispatch = vi.fn<DispatchFn>(async () => {});
|
||||
|
||||
const summary = await runMoodReminderTick(prisma as never, inWindow, {
|
||||
dispatch,
|
||||
});
|
||||
|
||||
expect(summary.dispatched).toBe(1);
|
||||
expect(summary.inWindow).toBe(1);
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
const firstCall = dispatch.mock.calls[0];
|
||||
if (!firstCall) throw new Error("dispatch was not called");
|
||||
const call = firstCall[0];
|
||||
expect(call.eventType).toBe("MOOD_REMINDER");
|
||||
expect(call.userId).toBe("u-1");
|
||||
expect(call.title).toBe("Stimmung erfassen");
|
||||
expect(call.message).toBe("Wie geht es dir heute?");
|
||||
expect(call.metadata?.localDate).toBe("2026-05-17");
|
||||
expect(typeof call.metadata?.scheduledAt).toBe("string");
|
||||
expect(state.dispatches).toHaveLength(1);
|
||||
expect(state.dispatches[0]).toEqual({ userId: "u-1", date: "2026-05-17" });
|
||||
});
|
||||
|
||||
it("does NOT dispatch for users outside the 22:00 window", async () => {
|
||||
const state: FakePrismaState = {
|
||||
candidates: [
|
||||
{
|
||||
id: "u-1",
|
||||
timezone: "Europe/Berlin",
|
||||
locale: "de",
|
||||
moodReminderEnabled: true,
|
||||
},
|
||||
],
|
||||
moodEntries: [],
|
||||
dispatches: [],
|
||||
raceUserIds: new Set(),
|
||||
};
|
||||
const prisma = makePrisma(state);
|
||||
const dispatch = vi.fn<DispatchFn>(async () => {});
|
||||
|
||||
const summary = await runMoodReminderTick(prisma as never, outOfWindow, {
|
||||
dispatch,
|
||||
});
|
||||
|
||||
expect(summary.dispatched).toBe(0);
|
||||
expect(summary.skippedOutsideWindow).toBe(1);
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never reads moodReminderEnabled=false users (opt-in gating)", async () => {
|
||||
const state: FakePrismaState = {
|
||||
candidates: [], // Prisma `where` already filters; we assert the call shape
|
||||
moodEntries: [],
|
||||
dispatches: [],
|
||||
raceUserIds: new Set(),
|
||||
};
|
||||
const prisma = makePrisma(state);
|
||||
const dispatch = vi.fn<DispatchFn>(async () => {});
|
||||
|
||||
await runMoodReminderTick(prisma as never, inWindow, { dispatch });
|
||||
|
||||
expect(prisma.user.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: { moodReminderEnabled: true } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips users who already logged a mood for the local date", async () => {
|
||||
const state: FakePrismaState = {
|
||||
candidates: [
|
||||
{
|
||||
id: "u-1",
|
||||
timezone: "Europe/Berlin",
|
||||
locale: "de",
|
||||
moodReminderEnabled: true,
|
||||
},
|
||||
],
|
||||
moodEntries: [{ userId: "u-1", date: "2026-05-17" }],
|
||||
dispatches: [],
|
||||
raceUserIds: new Set(),
|
||||
};
|
||||
const prisma = makePrisma(state);
|
||||
const dispatch = vi.fn<DispatchFn>(async () => {});
|
||||
|
||||
const summary = await runMoodReminderTick(prisma as never, inWindow, {
|
||||
dispatch,
|
||||
});
|
||||
|
||||
expect(summary.dispatched).toBe(0);
|
||||
expect(summary.skippedAlreadyLogged).toBe(1);
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
expect(state.dispatches).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("idempotency: a second tick inside the same window does not re-send", async () => {
|
||||
const state: FakePrismaState = {
|
||||
candidates: [
|
||||
{
|
||||
id: "u-1",
|
||||
timezone: "Europe/Berlin",
|
||||
locale: "de",
|
||||
moodReminderEnabled: true,
|
||||
},
|
||||
],
|
||||
moodEntries: [],
|
||||
// Existing dispatch row blocks the push.
|
||||
dispatches: [{ userId: "u-1", date: "2026-05-17" }],
|
||||
raceUserIds: new Set(),
|
||||
};
|
||||
const prisma = makePrisma(state);
|
||||
const dispatch = vi.fn<DispatchFn>(async () => {});
|
||||
|
||||
const summary = await runMoodReminderTick(prisma as never, inWindow, {
|
||||
dispatch,
|
||||
});
|
||||
|
||||
expect(summary.dispatched).toBe(0);
|
||||
expect(summary.skippedAlreadyDispatched).toBe(1);
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("idempotency: lost P2002 race is swallowed as 'already dispatched'", async () => {
|
||||
const state: FakePrismaState = {
|
||||
candidates: [
|
||||
{
|
||||
id: "u-1",
|
||||
timezone: "Europe/Berlin",
|
||||
locale: "de",
|
||||
moodReminderEnabled: true,
|
||||
},
|
||||
],
|
||||
moodEntries: [],
|
||||
dispatches: [],
|
||||
raceUserIds: new Set(["u-1"]),
|
||||
};
|
||||
const prisma = makePrisma(state);
|
||||
const dispatch = vi.fn<DispatchFn>(async () => {});
|
||||
|
||||
const summary = await runMoodReminderTick(prisma as never, inWindow, {
|
||||
dispatch,
|
||||
});
|
||||
|
||||
expect(summary.dispatched).toBe(0);
|
||||
expect(summary.skippedAlreadyDispatched).toBe(1);
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("dispatches per-user with their own locale (DE + EN mix)", async () => {
|
||||
const state: FakePrismaState = {
|
||||
candidates: [
|
||||
{
|
||||
id: "u-de",
|
||||
timezone: "Europe/Berlin",
|
||||
locale: "de",
|
||||
moodReminderEnabled: true,
|
||||
},
|
||||
{
|
||||
id: "u-en",
|
||||
timezone: "Europe/Berlin",
|
||||
locale: "en",
|
||||
moodReminderEnabled: true,
|
||||
},
|
||||
],
|
||||
moodEntries: [],
|
||||
dispatches: [],
|
||||
raceUserIds: new Set(),
|
||||
};
|
||||
const prisma = makePrisma(state);
|
||||
const dispatch = vi.fn<DispatchFn>(async () => {});
|
||||
|
||||
const summary = await runMoodReminderTick(prisma as never, inWindow, {
|
||||
dispatch,
|
||||
});
|
||||
|
||||
expect(summary.dispatched).toBe(2);
|
||||
expect(dispatch).toHaveBeenCalledTimes(2);
|
||||
const titles = dispatch.mock.calls.map((c) => {
|
||||
const [payload] = c;
|
||||
return payload?.title ?? "";
|
||||
});
|
||||
expect(titles).toContain("Stimmung erfassen");
|
||||
expect(titles).toContain("Log your mood");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* v0.5.4 ios-coord — daily mood-reminder dispatcher.
|
||||
*
|
||||
* The reminder cron fires every 15 minutes (same cadence as the
|
||||
* medication-reminder loop in `reminder-worker.ts`). When the cron lands
|
||||
* inside the 22:00 hour in a user's local timezone, this module:
|
||||
*
|
||||
* 1. Skips users who haven't opted in (`User.moodReminderEnabled = false`).
|
||||
* 2. Skips users who already logged a mood entry for the local date.
|
||||
* 3. Skips users who already received a mood reminder today
|
||||
* (idempotency via `MoodReminderDispatch` row).
|
||||
* 4. Dispatches a `MOOD_REMINDER` notification with a locale-aware
|
||||
* title + body and records the dispatch ledger row in the same
|
||||
* transaction so a concurrent worker can't double-fire.
|
||||
*
|
||||
* Why a separate module instead of folding it into reminder-worker.ts:
|
||||
* the worker is already 1800 LOC. Splitting the mood-reminder logic out
|
||||
* keeps the test surface focused and lets the unit tests run without
|
||||
* booting pg-boss.
|
||||
*/
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import type { Locale } from "@/lib/i18n/config";
|
||||
import { defaultLocale } from "@/lib/i18n/config";
|
||||
import { getLocalDateParts } from "@/lib/timezone";
|
||||
import { dispatchNotification } from "@/lib/notifications/dispatcher";
|
||||
|
||||
/**
|
||||
* Hour of day (in the user's local timezone) at which the mood reminder
|
||||
* should fire. 22:00 is late enough that a "did you forget to log?" nudge
|
||||
* lands in the user's wind-down window without colliding with dinner or
|
||||
* the medication-reminder hot path.
|
||||
*
|
||||
* Exported so the unit tests can pin the contract without scraping the
|
||||
* handler internals.
|
||||
*/
|
||||
export const MOOD_REMINDER_LOCAL_HOUR = 22;
|
||||
|
||||
/**
|
||||
* Minimal user shape needed to decide whether to dispatch. Defined
|
||||
* explicitly so the handler can run against either the global Prisma
|
||||
* client or the per-worker singleton without re-deriving the type.
|
||||
*/
|
||||
export interface MoodReminderCandidate {
|
||||
id: string;
|
||||
timezone: string;
|
||||
locale: string | null;
|
||||
moodReminderEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface MoodReminderSummary {
|
||||
candidatesScanned: number;
|
||||
inWindow: number;
|
||||
dispatched: number;
|
||||
skippedAlreadyLogged: number;
|
||||
skippedAlreadyDispatched: number;
|
||||
skippedOutsideWindow: number;
|
||||
}
|
||||
|
||||
function resolveLocale(locale: string | null | undefined): Locale {
|
||||
return locale === "en" || locale === "de" ? locale : defaultLocale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure decision predicate: should the worker fire a mood reminder for
|
||||
* this user at this instant?
|
||||
*
|
||||
* Returns the local YYYY-MM-DD date string when the answer is "fire";
|
||||
* returns `null` when the answer is "skip" — covers both
|
||||
* "outside-window" and "opt-out". Callers branch on the return value.
|
||||
*
|
||||
* Pulled out as a pure helper so the unit tests can pin the
|
||||
* window-boundary contract (21:59 → null, 22:00 → date, 22:59 → date,
|
||||
* 23:00 → null) without touching the DB.
|
||||
*/
|
||||
export function evaluateMoodReminderWindow(
|
||||
user: Pick<MoodReminderCandidate, "timezone" | "moodReminderEnabled">,
|
||||
now: Date,
|
||||
): { fire: boolean; localDate: string | null; localHour: number } {
|
||||
if (!user.moodReminderEnabled) {
|
||||
return { fire: false, localDate: null, localHour: -1 };
|
||||
}
|
||||
const parts = getLocalDateParts(now, user.timezone || "Europe/Berlin");
|
||||
const inWindow = parts.hour === MOOD_REMINDER_LOCAL_HOUR;
|
||||
const isoDate = `${parts.year.toString().padStart(4, "0")}-${parts.month
|
||||
.toString()
|
||||
.padStart(2, "0")}-${parts.day.toString().padStart(2, "0")}`;
|
||||
return {
|
||||
fire: inWindow,
|
||||
localDate: inWindow ? isoDate : null,
|
||||
localHour: parts.hour,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the localised title + body for the mood reminder push. Pulled
|
||||
* out so a future template change doesn't require re-running the whole
|
||||
* handler in tests.
|
||||
*/
|
||||
export function buildMoodReminderPayload(locale: string | null | undefined): {
|
||||
title: string;
|
||||
body: string;
|
||||
} {
|
||||
const t = getServerTranslator(resolveLocale(locale)).t;
|
||||
return {
|
||||
title: t("moodReminders.dailyTitle"),
|
||||
body: t("moodReminders.dailyBody"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one mood-reminder cron tick. Iterates every user with
|
||||
* `moodReminderEnabled = true`, checks the per-user local-time window,
|
||||
* and dispatches a `MOOD_REMINDER` notification when warranted.
|
||||
*
|
||||
* The dedup ledger (`MoodReminderDispatch`) is the idempotency anchor:
|
||||
* the `(userId, date)` unique constraint guarantees that two parallel
|
||||
* workers (or a re-tick inside the same hour) won't double-fire.
|
||||
*
|
||||
* Returns a summary so the worker can attach it to the wide-event for
|
||||
* the observability dashboards.
|
||||
*/
|
||||
export async function runMoodReminderTick(
|
||||
prisma: PrismaClient,
|
||||
now: Date,
|
||||
options: {
|
||||
dispatch?: typeof dispatchNotification;
|
||||
} = {},
|
||||
): Promise<MoodReminderSummary> {
|
||||
const dispatchImpl = options.dispatch ?? dispatchNotification;
|
||||
|
||||
const summary: MoodReminderSummary = {
|
||||
candidatesScanned: 0,
|
||||
inWindow: 0,
|
||||
dispatched: 0,
|
||||
skippedAlreadyLogged: 0,
|
||||
skippedAlreadyDispatched: 0,
|
||||
skippedOutsideWindow: 0,
|
||||
};
|
||||
|
||||
const candidates = await prisma.user.findMany({
|
||||
where: { moodReminderEnabled: true },
|
||||
select: {
|
||||
id: true,
|
||||
timezone: true,
|
||||
locale: true,
|
||||
moodReminderEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const user of candidates) {
|
||||
summary.candidatesScanned += 1;
|
||||
const decision = evaluateMoodReminderWindow(
|
||||
{
|
||||
timezone: user.timezone,
|
||||
moodReminderEnabled: user.moodReminderEnabled,
|
||||
},
|
||||
now,
|
||||
);
|
||||
|
||||
if (!decision.fire || !decision.localDate) {
|
||||
summary.skippedOutsideWindow += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
summary.inWindow += 1;
|
||||
|
||||
// Already nudged today? The dedup row is the source of truth — if
|
||||
// a previous tick succeeded, this branch swallows the work. We
|
||||
// check before the MoodEntry lookup because the ledger query is
|
||||
// strictly cheaper (single indexed row) than scanning today's
|
||||
// mood entries.
|
||||
const existingDispatch = await prisma.moodReminderDispatch.findUnique({
|
||||
where: { userId_date: { userId: user.id, date: decision.localDate } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existingDispatch) {
|
||||
summary.skippedAlreadyDispatched += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip when the user already logged a mood for the local date. Same
|
||||
// anchoring convention as `MoodEntry.date` (YYYY-MM-DD in the user's
|
||||
// current timezone). Legacy rows that pre-date the `tz` column are
|
||||
// already pinned to Europe/Berlin via the storage convention; here
|
||||
// we accept whatever `date` string the row carries because the
|
||||
// user-experience contract is "I logged today, don't nudge me".
|
||||
const existingMood = await prisma.moodEntry.findFirst({
|
||||
where: { userId: user.id, date: decision.localDate },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existingMood) {
|
||||
summary.skippedAlreadyLogged += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Reserve the dedup row FIRST so a parallel worker that races us
|
||||
// hits the unique-constraint violation and bails before fanning a
|
||||
// duplicate push. We catch the P2002 unique-violation path
|
||||
// explicitly because a "lost the race" outcome is not a worker
|
||||
// error — it's the idempotency contract doing its job.
|
||||
try {
|
||||
await prisma.moodReminderDispatch.create({
|
||||
data: { userId: user.id, date: decision.localDate },
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
// Prisma encodes unique-violation as `code === 'P2002'`. Detect
|
||||
// by string-includes rather than instanceof so this module
|
||||
// doesn't pull in the Prisma runtime types just for the guard.
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes("P2002") || message.includes("Unique constraint")) {
|
||||
summary.skippedAlreadyDispatched += 1;
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const { title, body } = buildMoodReminderPayload(user.locale);
|
||||
|
||||
await dispatchImpl({
|
||||
eventType: "MOOD_REMINDER",
|
||||
userId: user.id,
|
||||
title,
|
||||
message: body,
|
||||
metadata: {
|
||||
scheduledAt: now.toISOString(),
|
||||
localDate: decision.localDate,
|
||||
},
|
||||
});
|
||||
|
||||
summary.dispatched += 1;
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
@@ -83,6 +83,7 @@ import {
|
||||
getPhaseMessage,
|
||||
getPhaseKeyboard,
|
||||
} from "@/lib/jobs/reminder-phases";
|
||||
import { runMoodReminderTick } from "@/lib/jobs/mood-reminder";
|
||||
import { withBackgroundEvent } from "@/lib/logging/background";
|
||||
import { assertSubsystemEnabled } from "@/lib/process-type";
|
||||
import { runOffhostBackup } from "@/lib/jobs/offhost-backup";
|
||||
@@ -179,6 +180,17 @@ const FEEDBACK_AGGREGATOR_CRON = "0 4 * * *";
|
||||
// fall to the drain.
|
||||
const DRAIN_CUMULATIVE_QUEUE = "drain-per-sample-cumulative";
|
||||
const DRAIN_CUMULATIVE_CRON = "45 3 * * *";
|
||||
// v0.5.4 ios-coord — daily mood-reminder cron.
|
||||
//
|
||||
// Runs every 15 minutes so the handler can pick up users whose local
|
||||
// time has just crossed 22:00 across any IANA timezone without having
|
||||
// to schedule one cron entry per zone. The handler short-circuits when
|
||||
// the user's local hour isn't 22, so the 15-min cadence translates to
|
||||
// ~4 ticks-per-hour × 1 actual-dispatch-window-per-user = at most one
|
||||
// push per user per day. Idempotency is enforced by the
|
||||
// `MoodReminderDispatch` ledger inside the handler.
|
||||
const MOOD_REMINDER_QUEUE = "mood-reminder-check";
|
||||
const MOOD_REMINDER_CRON = "*/15 * * * *";
|
||||
// v1.4.38 — the per-sample cutoff hours constant now lives on the
|
||||
// helper module so the worker, the admin route, and the CLI all read
|
||||
// the same source of truth. Re-export pulled in alongside
|
||||
@@ -281,6 +293,10 @@ interface GeoBackfillPayload {
|
||||
triggeredAt: string;
|
||||
}
|
||||
|
||||
interface MoodReminderPayload {
|
||||
triggeredAt: string;
|
||||
}
|
||||
|
||||
// Re-export timezone utilities under local names for backward compatibility
|
||||
const getUserTodayBounds = getUserTodayBoundsUtil;
|
||||
const getDayOfWeekInTz = getDayOfWeekInTzUtil;
|
||||
@@ -559,6 +575,17 @@ async function handleReminderCheck(jobs: Job<ReminderCheckPayload>[]) {
|
||||
med.user.locale,
|
||||
);
|
||||
|
||||
// v0.5.4 — surface the window-start as an ISO 8601 string so
|
||||
// the iOS notification handler can pin a "snooze 15 min"
|
||||
// action against the actual schedule slot rather than the
|
||||
// wall-clock moment APNs delivered. Computed in UTC from the
|
||||
// user's localised window-start; mirrors the RED-phase
|
||||
// `scheduledFor` field on the intake event.
|
||||
const [winH, winM] = schedule.windowStart.split(":").map(Number);
|
||||
const scheduledAtIso = new Date(
|
||||
todayStart.getTime() + winH * 3600000 + winM * 60000,
|
||||
).toISOString();
|
||||
|
||||
evt.addMeta(
|
||||
"notification_phase",
|
||||
`${currentPhase}:${med.name}:${schedule.windowStart}-${schedule.windowEnd}`,
|
||||
@@ -575,6 +602,7 @@ async function handleReminderCheck(jobs: Job<ReminderCheckPayload>[]) {
|
||||
scheduleId: schedule.id,
|
||||
phase: currentPhase,
|
||||
date: localDateStr,
|
||||
scheduledAt: scheduledAtIso,
|
||||
replyMarkup: keyboard,
|
||||
},
|
||||
});
|
||||
@@ -717,9 +745,7 @@ async function handleWithingsActivitySync(
|
||||
* activity handler: per-user when the webhook fires, full-iteration
|
||||
* when the cron ticks.
|
||||
*/
|
||||
async function handleWithingsSleepSync(
|
||||
jobs: Job<WithingsSleepSyncPayload>[],
|
||||
) {
|
||||
async function handleWithingsSleepSync(jobs: Job<WithingsSleepSyncPayload>[]) {
|
||||
await withBackgroundEvent("job.withings_sleep_sync", async (evt) => {
|
||||
const prisma = getWorkerPrisma();
|
||||
try {
|
||||
@@ -1107,6 +1133,39 @@ async function handleMoodLogSync(jobs: Job<MoodLogSyncPayload>[]) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.5.4 ios-coord — daily mood-reminder dispatcher.
|
||||
*
|
||||
* Delegates the dispatch decision to `runMoodReminderTick` in
|
||||
* `mood-reminder.ts` so the unit tests can exercise the logic without
|
||||
* spinning up pg-boss. The handler is a thin shim that wires the worker
|
||||
* Prisma singleton + the wide-event sink to the pure function.
|
||||
*/
|
||||
async function handleMoodReminderCheck(jobs: Job<MoodReminderPayload>[]) {
|
||||
void jobs;
|
||||
await withBackgroundEvent("job.mood_reminder", async (evt) => {
|
||||
const prisma = getWorkerPrisma();
|
||||
try {
|
||||
const summary = await runMoodReminderTick(prisma, new Date());
|
||||
evt.setBackground({
|
||||
task_name: "job.mood_reminder",
|
||||
result: {
|
||||
candidates_scanned: summary.candidatesScanned,
|
||||
in_window: summary.inWindow,
|
||||
dispatched: summary.dispatched,
|
||||
skipped_already_logged: summary.skippedAlreadyLogged,
|
||||
skipped_already_dispatched: summary.skippedAlreadyDispatched,
|
||||
skipped_outside_window: summary.skippedOutsideWindow,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
evt.setError(err);
|
||||
recordError();
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRateLimitCleanup(jobs: Job<RateLimitCleanupPayload>[]) {
|
||||
void jobs;
|
||||
await withBackgroundEvent("job.rate_limit_cleanup", async (evt) => {
|
||||
@@ -1248,8 +1307,10 @@ async function handlePrDetection(
|
||||
// users in that case. The push-suppression flag is irrelevant
|
||||
// for the cron path (the dispatcher's per-user opt-in handles
|
||||
// the loud/quiet decision once the row is written).
|
||||
const payloadUserId = (job.data as PrDetectionPayload | undefined)?.userId;
|
||||
const silent = (job.data as PrDetectionPayload | undefined)?.silent ?? false;
|
||||
const payloadUserId = (job.data as PrDetectionPayload | undefined)
|
||||
?.userId;
|
||||
const silent =
|
||||
(job.data as PrDetectionPayload | undefined)?.silent ?? false;
|
||||
const userIds: string[] = payloadUserId
|
||||
? [payloadUserId]
|
||||
: (await p.user.findMany({ select: { id: true } })).map((u) => u.id);
|
||||
@@ -1560,6 +1621,11 @@ export async function startReminderWorker() {
|
||||
// this entry the drain schedule silently no-ops and the
|
||||
// per-sample APPLE_HEALTH rows never collapse.
|
||||
DRAIN_CUMULATIVE_QUEUE,
|
||||
// v0.5.4 ios-coord — mood-reminder cron tick. Same pg-boss v12
|
||||
// createQueue contract as the drain queue above; without this
|
||||
// entry the every-15-min schedule silently no-ops and the
|
||||
// dispatcher never fires.
|
||||
MOOD_REMINDER_QUEUE,
|
||||
];
|
||||
|
||||
for (const q of allQueues) {
|
||||
@@ -1573,11 +1639,7 @@ export async function startReminderWorker() {
|
||||
try {
|
||||
await reconcileOrphanImportJobs();
|
||||
} catch (err) {
|
||||
workerLog(
|
||||
"error",
|
||||
"Failed to reconcile orphan ImportJob rows",
|
||||
err,
|
||||
);
|
||||
workerLog("error", "Failed to reconcile orphan ImportJob rows", err);
|
||||
}
|
||||
|
||||
// Schedule recurring cron jobs
|
||||
@@ -1620,6 +1682,11 @@ export async function startReminderWorker() {
|
||||
// rows into one row per day per type. Slots between the
|
||||
// audit-log cleanup (03:15) and the feedback aggregator (04:00).
|
||||
[DRAIN_CUMULATIVE_QUEUE, DRAIN_CUMULATIVE_CRON],
|
||||
// v0.5.4 ios-coord — every-15-min tick for the daily mood reminder.
|
||||
// The handler short-circuits unless the candidate user's local
|
||||
// time is the 22:00 hour, so the cron costs ~one user-row scan
|
||||
// per tick for the entire opted-in cohort.
|
||||
[MOOD_REMINDER_QUEUE, MOOD_REMINDER_CRON],
|
||||
];
|
||||
|
||||
for (const [name, cron] of schedules) {
|
||||
@@ -1727,6 +1794,15 @@ export async function startReminderWorker() {
|
||||
{ localConcurrency: 1 },
|
||||
handleGeoBackfill,
|
||||
);
|
||||
// v0.5.4 ios-coord — single-flight worker. localConcurrency=1 keeps
|
||||
// two reminder ticks from interleaving against the same user row;
|
||||
// the dedup ledger would still save us, but skipping the race here
|
||||
// avoids spurious P2002 errors in the wide-event log.
|
||||
await boss.work<MoodReminderPayload>(
|
||||
MOOD_REMINDER_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleMoodReminderCheck,
|
||||
);
|
||||
await boss.work<PrDetectionPayload>(
|
||||
PR_DETECTION_QUEUE,
|
||||
{ localConcurrency: PR_DETECTION_CONCURRENCY },
|
||||
|
||||
@@ -39,6 +39,8 @@ vi.mock("@parse/node-apn", () => {
|
||||
public threadId: string | undefined;
|
||||
public collapseId: string | undefined;
|
||||
public payload: unknown;
|
||||
public category: string | undefined;
|
||||
public mutableContent: boolean | undefined;
|
||||
constructor() {
|
||||
notificationCtorMock();
|
||||
}
|
||||
@@ -419,4 +421,68 @@ describe("sendViaApns — dispatcher fan-out", () => {
|
||||
const note = sendMock.mock.calls[0][0];
|
||||
expect(note.alert.body).toBe("take meds");
|
||||
});
|
||||
|
||||
it("forwards eventType as aps.category so iOS renders action buttons", async () => {
|
||||
// Wires the v0.5.4 contract: MEDICATION_REMINDER on the server must
|
||||
// surface as `aps.category = "MEDICATION_REMINDER"` so iOS looks up
|
||||
// the UNNotificationCategory the app registered at launch and
|
||||
// renders Take / Snooze 15 / Skip on the lock-screen.
|
||||
vi.mocked(prisma.device.findMany).mockResolvedValueOnce([
|
||||
{ id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" },
|
||||
] as never);
|
||||
sendMock.mockResolvedValueOnce({ sent: [{ device: "tok-a" }], failed: [] });
|
||||
await sendViaApns("u-1", {
|
||||
title: "t",
|
||||
message: "m",
|
||||
eventType: "MEDICATION_REMINDER",
|
||||
metadata: { medicationId: "med-1", scheduleId: "sched-1" },
|
||||
});
|
||||
const note = sendMock.mock.calls[0][0];
|
||||
expect(note.category).toBe("MEDICATION_REMINDER");
|
||||
expect(note.mutableContent).toBe(true);
|
||||
expect(note.threadId).toBe("MEDICATION_REMINDER");
|
||||
expect(note.payload.medicationId).toBe("med-1");
|
||||
expect(note.payload.scheduleId).toBe("sched-1");
|
||||
expect(note.payload.eventType).toBe("MEDICATION_REMINDER");
|
||||
});
|
||||
|
||||
it("forwards MOOD_REMINDER eventType as aps.category too", async () => {
|
||||
// SR-2 contract: the mood-reminder daily job emits this event-type;
|
||||
// the iOS app registers a `MOOD_REMINDER` category whose only
|
||||
// action opens the mood-entry sheet.
|
||||
vi.mocked(prisma.device.findMany).mockResolvedValueOnce([
|
||||
{ id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" },
|
||||
] as never);
|
||||
sendMock.mockResolvedValueOnce({ sent: [{ device: "tok-a" }], failed: [] });
|
||||
await sendViaApns("u-1", {
|
||||
title: "Stimmung erfassen",
|
||||
message: "Wie geht es dir heute?",
|
||||
eventType: "MOOD_REMINDER",
|
||||
metadata: { scheduledAt: "2026-05-17T20:00:00.000Z" },
|
||||
});
|
||||
const note = sendMock.mock.calls[0][0];
|
||||
expect(note.category).toBe("MOOD_REMINDER");
|
||||
expect(note.threadId).toBe("MOOD_REMINDER");
|
||||
expect(note.payload.scheduledAt).toBe("2026-05-17T20:00:00.000Z");
|
||||
});
|
||||
|
||||
it("forwards explicit category override on sendApnsPush", async () => {
|
||||
// Lower-level entry — directly setting `payload.category` must
|
||||
// propagate to `note.category` so callers that bypass `sendViaApns`
|
||||
// (e.g. an admin test endpoint) keep full control.
|
||||
setEnv(VALID_ENV);
|
||||
sendMock.mockResolvedValueOnce({ sent: [{ device: "abc" }], failed: [] });
|
||||
await sendApnsPush({
|
||||
deviceToken: "abc",
|
||||
environment: "sandbox",
|
||||
payload: {
|
||||
alert: { title: "t", body: "b" },
|
||||
category: "CUSTOM_CATEGORY",
|
||||
mutableContent: true,
|
||||
},
|
||||
});
|
||||
const note = sendMock.mock.calls[0][0];
|
||||
expect(note.category).toBe("CUSTOM_CATEGORY");
|
||||
expect(note.mutableContent).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,25 @@ export interface ApnsPayload {
|
||||
data?: Record<string, unknown>;
|
||||
/** APNs `aps.thread-id` — groups related alerts in Notification Center. */
|
||||
threadId?: string;
|
||||
/**
|
||||
* APNs `aps.category` — names a `UNNotificationCategory` registered by
|
||||
* the iOS app at launch. The category drives the action-button row on
|
||||
* the lock-screen / Notification Center entry. iOS HealthLog v0.5.3
|
||||
* registers `MEDICATION_REMINDER` (Take / Snooze 15 / Skip) and
|
||||
* `MOOD_REMINDER` (Log mood). Omit the field for plain alerts without
|
||||
* actions.
|
||||
*/
|
||||
category?: string;
|
||||
/**
|
||||
* Sets `aps.mutable-content = 1`. iOS routes the payload through the
|
||||
* app's Notification Service Extension before the system renders it,
|
||||
* giving the extension a chance to enrich the alert (e.g. download a
|
||||
* thumbnail, decrypt a body). HealthLog doesn't ship an NSE yet, but
|
||||
* the flag is cheap to set and a no-op without one — wiring it now
|
||||
* means the iOS team can drop in an NSE later without a server-side
|
||||
* roundtrip.
|
||||
*/
|
||||
mutableContent?: boolean;
|
||||
}
|
||||
|
||||
export interface ApnsSendInput {
|
||||
@@ -229,6 +248,27 @@ export async function sendApnsPush(
|
||||
if (input.payload.threadId) {
|
||||
note.threadId = input.payload.threadId;
|
||||
}
|
||||
if (input.payload.category) {
|
||||
// node-apn's `category` setter writes through to `aps.category` —
|
||||
// that's the key the iOS notification system reads to look up a
|
||||
// `UNNotificationCategory` and render its action-button row. The
|
||||
// category name is a plain string that must match an identifier
|
||||
// registered by the iOS app at launch (see iOS HealthLog v0.5.3
|
||||
// `NotificationCategories`).
|
||||
//
|
||||
// The cast is because node-apn 8.1's d.ts file omits the public
|
||||
// setter (it only documents the matching `aps.category` field on
|
||||
// the `Aps` interface), but the runtime setter on
|
||||
// `apsProperties.js` writes the value through unchanged. We assign
|
||||
// via the `category` setter rather than poking `note.aps.category`
|
||||
// directly so we stay forward-compatible with future d.ts fixes.
|
||||
(note as unknown as { category: string }).category = input.payload.category;
|
||||
}
|
||||
if (input.payload.mutableContent) {
|
||||
// node-apn writes this through to `aps.mutable-content = 1`, which
|
||||
// unlocks the iOS Notification Service Extension hook.
|
||||
note.mutableContent = true;
|
||||
}
|
||||
if (input.collapseId) {
|
||||
note.collapseId = input.collapseId;
|
||||
}
|
||||
@@ -356,6 +396,17 @@ export async function sendViaApns(
|
||||
...(payload.metadata ?? {}),
|
||||
},
|
||||
threadId: payload.eventType,
|
||||
// The iOS app registers a `UNNotificationCategory` per event-type
|
||||
// so the same string doubles as the category identifier. iOS
|
||||
// ignores categories it doesn't know about, so adding the key
|
||||
// here is safe for event-types that don't have an actionable
|
||||
// category registered yet — they fall back to a plain alert.
|
||||
category: payload.eventType,
|
||||
// Future-proof for an iOS Notification Service Extension. iOS
|
||||
// ignores the flag when no extension is registered, so it's a
|
||||
// no-op on today's binary but unblocks NSE work without a
|
||||
// server-side change.
|
||||
mutableContent: true,
|
||||
},
|
||||
collapseId: payload.eventType,
|
||||
});
|
||||
|
||||
@@ -17,6 +17,12 @@ export const EVENT_TYPES = [
|
||||
// below) so a multi-year Apple Health backfill doesn't fire hundreds
|
||||
// of pushes during initial sync.
|
||||
"PERSONAL_RECORD",
|
||||
// v0.5.4 ios-coord — daily mood-reminder nudge. Default OFF on the
|
||||
// per-event policy (see DEFAULT_EVENT_PREFS) and additionally gated
|
||||
// on the per-user `moodReminderEnabled` flag, so a user has to opt
|
||||
// in twice before the server starts nudging them about an
|
||||
// emotionally-loaded surface.
|
||||
"MOOD_REMINDER",
|
||||
] as const;
|
||||
export type EventType = (typeof EVENT_TYPES)[number];
|
||||
|
||||
@@ -38,6 +44,11 @@ export const EVENT_DEFAULT_ENABLED: Record<EventType, boolean> = {
|
||||
WITHINGS_SYNC_FAILED: true,
|
||||
SYSTEM_ALERT: true,
|
||||
PERSONAL_RECORD: false,
|
||||
// v0.5.4 ios-coord — see note above on `EVENT_TYPES`. Default OFF
|
||||
// is the safer posture: even if a future surface forgets to gate on
|
||||
// `User.moodReminderEnabled`, this per-event default also has to be
|
||||
// flipped on before the dispatcher will surface the channel.
|
||||
MOOD_REMINDER: false,
|
||||
};
|
||||
|
||||
export const CHANNEL_TYPE_LABELS: Record<ChannelType, string> = {
|
||||
|
||||
Reference in new issue
Block a user