From 75c74f1c49083a0ea1fd1b37ddc269b464859505 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 10 May 2026 10:33:47 +0200 Subject: [PATCH] feat(achievements): worker re-evaluates new + hidden achievements on data change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two trigger fixes so the new achievements actually fire end-to-end. The doctor-PDF hidden Easter-egg used to watch a non-existent `doctor-report.export` audit action. The real route emits two: `doctor-report.generate` (the JSON helper) and `doctor-report.pdf.generate` (the PDF route). Either now counts. The locale-flip hidden Easter-egg used to watch `settings.locale.update`, which no endpoint emits — the locale field shares the catch-all `profile.update` audit row with every other profile field. Profile-update now emits a separate `settings.locale.update` row when (and only when) the locale value actually changes (compares the new value to the prior persisted locale, suppresses the row when they match), with the from/to values in the details payload for traceability. Already-existing TanStack Query invalidation map covers the new metrics: `measurementDependentKeys`, `moodDependentKeys`, and `medicationDependentKeys` already include `queryKeys.gamificationAchievements()`, so any measurement / mood / intake mutation triggers a refetch and the route persists the new unlock to `UserAchievement` (existing v1.4.15 mechanism). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/gamification/achievements/route.ts | 3 ++- src/lib/auth/profile-update.ts | 23 +++++++++++++++++++ .../__tests__/expansion-metrics.test.ts | 5 ++-- src/lib/gamification/expansion-metrics.ts | 11 ++++++++- .../achievements-expansion.test.ts | 4 ++-- 5 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/app/api/gamification/achievements/route.ts b/src/app/api/gamification/achievements/route.ts index a04e2f5b0..e58567730 100644 --- a/src/app/api/gamification/achievements/route.ts +++ b/src/app/api/gamification/achievements/route.ts @@ -531,7 +531,8 @@ export const GET = apiHandler(async (request: NextRequest) => { "auth.login.password", "bugreport.submit", // v1.4.18 — hidden Easter-egg triggers - "doctor-report.export", + "doctor-report.generate", + "doctor-report.pdf.generate", "settings.locale.update", ], }, diff --git a/src/lib/auth/profile-update.ts b/src/lib/auth/profile-update.ts index 03c0de7c3..07391127d 100644 --- a/src/lib/auth/profile-update.ts +++ b/src/lib/auth/profile-update.ts @@ -86,6 +86,21 @@ export async function applyProfileUpdate( if (data.locale !== undefined) updates.locale = data.locale; if (data.timezone !== undefined) updates.timezone = data.timezone; + // v1.4.18 — capture the prior locale so we can emit a separate + // `settings.locale.update` audit row when the locale actually + // changes. The hidden "polyglot" achievement watches that action, + // and we don't want to fire it on profile updates that happen to + // touch other fields. + const priorLocale = + data.locale !== undefined + ? ( + await prisma.user.findUnique({ + where: { id: userId }, + select: { locale: true }, + }) + )?.locale ?? null + : null; + const updatedUser = await prisma.user.update({ where: { id: userId }, data: updates, @@ -96,6 +111,14 @@ export async function applyProfileUpdate( ipAddress: ipAddress ?? null, }); + if (data.locale !== undefined && priorLocale !== data.locale) { + await auditLog("settings.locale.update", { + userId: updatedUser.id, + ipAddress: ipAddress ?? null, + details: { from: priorLocale, to: data.locale ?? null }, + }); + } + return { ok: true, user: { diff --git a/src/lib/gamification/__tests__/expansion-metrics.test.ts b/src/lib/gamification/__tests__/expansion-metrics.test.ts index f6b3ff7ab..ab71c28e4 100644 --- a/src/lib/gamification/__tests__/expansion-metrics.test.ts +++ b/src/lib/gamification/__tests__/expansion-metrics.test.ts @@ -235,8 +235,9 @@ describe("getHiddenMetrics", () => { moodEntries: [], intakeEvents: [], auditEvents: [ - { action: "doctor-report.export", createdAt: new Date() }, - { action: "doctor-report.export", createdAt: new Date() }, + // The route's auditLog filter accepts both `generate` actions. + { action: "doctor-report.generate", createdAt: new Date() }, + { action: "doctor-report.pdf.generate", createdAt: new Date() }, { action: "settings.locale.update", createdAt: new Date() }, { action: "auth.login.passkey", createdAt: new Date() }, // ignored ], diff --git a/src/lib/gamification/expansion-metrics.ts b/src/lib/gamification/expansion-metrics.ts index 6157b5e30..899135d72 100644 --- a/src/lib/gamification/expansion-metrics.ts +++ b/src/lib/gamification/expansion-metrics.ts @@ -276,7 +276,16 @@ export function getHiddenMetrics(input: { let doctorPdfCount = 0; let localeFlipCount = 0; for (const ev of input.auditEvents) { - if (ev.action === "doctor-report.export") doctorPdfCount += 1; + // Both "generate" + "pdf.generate" count — the PDF route emits the + // latter; the JSON-only generate route emits the former. We accept + // either as the trigger so the badge fires the first time the user + // exercises the doctor-report feature in any shape. + if ( + ev.action === "doctor-report.generate" || + ev.action === "doctor-report.pdf.generate" + ) { + doctorPdfCount += 1; + } if (ev.action === "settings.locale.update") localeFlipCount += 1; } diff --git a/tests/integration/achievements-expansion.test.ts b/tests/integration/achievements-expansion.test.ts index 3c15aec35..5a5c36d35 100644 --- a/tests/integration/achievements-expansion.test.ts +++ b/tests/integration/achievements-expansion.test.ts @@ -159,12 +159,12 @@ describe("achievements expansion — fresh user, real prisma rows", () => { await prisma.auditLog.create({ data: { userId: user.id, - action: "doctor-report.export", + action: "doctor-report.pdf.generate", }, }); const auditEvents = await prisma.auditLog.findMany({ - where: { userId: user.id, action: "doctor-report.export" }, + where: { userId: user.id, action: "doctor-report.pdf.generate" }, select: { action: true, createdAt: true }, });