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:
Marc-André Bombeck committed 2026-05-17 23:10:32 +02:00
commit 07574480c9
17 files changed
+1311 -1065

No files matched your search

+1 -1
View File
@@ -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
+4
View File
@@ -2987,5 +2987,9 @@
"buttonSnoozeOneHour": "🕐 1h",
"buttonSnoozeThreeHours": "🕐 3h",
"buttonSkip": "⏭ Überspringen"
},
"moodReminders": {
"dailyTitle": "Stimmung erfassen",
"dailyBody": "Wie geht es dir heute?"
}
}
+4
View File
@@ -2987,5 +2987,9 @@
"buttonSnoozeOneHour": "🕐 1h",
"buttonSnoozeThreeHours": "🕐 3h",
"buttonSkip": "⏭ Skip"
},
"moodReminders": {
"dailyTitle": "Log your mood",
"dailyBody": "How are you feeling today?"
}
}
+4
View File
@@ -2987,5 +2987,9 @@
"buttonSnoozeOneHour": "🕐 1h",
"buttonSnoozeThreeHours": "🕐 3h",
"buttonSkip": "⏭ Omitir"
},
"moodReminders": {
"dailyTitle": "Registrar el ánimo",
"dailyBody": "¿Cómo te sientes hoy?"
}
}
+4
View File
@@ -2987,5 +2987,9 @@
"buttonSnoozeOneHour": "🕐 1h",
"buttonSnoozeThreeHours": "🕐 3h",
"buttonSkip": "⏭ Ignorer"
},
"moodReminders": {
"dailyTitle": "Enregistrer votre humeur",
"dailyBody": "Comment vous sentez-vous aujourdhui ?"
}
}
+4
View File
@@ -2987,5 +2987,9 @@
"buttonSnoozeOneHour": "🕐 1h",
"buttonSnoozeThreeHours": "🕐 3h",
"buttonSkip": "⏭ Salta"
},
"moodReminders": {
"dailyTitle": "Registra il tuo umore",
"dailyBody": "Come ti senti oggi?"
}
}
+4
View File
@@ -2987,5 +2987,9 @@
"buttonSnoozeOneHour": "🕐 1h",
"buttonSnoozeThreeHours": "🕐 3h",
"buttonSkip": "⏭ Pomiń"
},
"moodReminders": {
"dailyTitle": "Zapisz nastrój",
"dailyBody": "Jak się dzisiaj czujesz?"
}
}
+3 -3
View File
@@ -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",
+375 -1051
View File
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 $$;
+38
View File
@@ -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");
});
});
+235
View File
@@ -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;
}
+86 -10
View File
@@ -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);
});
});
+51
View File
@@ -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,
});
+11
View File
@@ -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> = {