From 83251848e4b7fc4064a4eca6a22f68dccb0b35fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 3 May 2026 21:42:50 +0200 Subject: [PATCH] feat(achievements): optional ?format=ios with translated copy Adds an opt-in `?format=ios` query parameter to `GET /api/gamification/achievements`. When set, the response collapses the existing `{ achievements, summary }` envelope into a flat `Achievement[]` with translated `title`/`description` (server-side `t()` honouring `Accept-Language` + `user.locale`) and `progress` normalised to 0..1. Default response (no query param) is unchanged. Existing web client keeps receiving the wrapped `{ achievements, summary }` plus i18n keys exactly as before. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../achievements/__tests__/ios-format.test.ts | 114 ++++++++++++++++++ .../api/gamification/achievements/route.ts | 42 ++++++- 2 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 src/app/api/gamification/achievements/__tests__/ios-format.test.ts diff --git a/src/app/api/gamification/achievements/__tests__/ios-format.test.ts b/src/app/api/gamification/achievements/__tests__/ios-format.test.ts new file mode 100644 index 000000000..380be4080 --- /dev/null +++ b/src/app/api/gamification/achievements/__tests__/ios-format.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { findMany: vi.fn() }, + medicationIntakeEvent: { findMany: vi.fn() }, + medication: { findMany: vi.fn() }, + passkey: { findMany: vi.fn() }, + auditLog: { findMany: vi.fn() }, + userAchievement: { + findMany: vi.fn(), + createMany: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })), +})); + +import { GET } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "marc", + role: "USER" as const, + createdAt: new Date("2026-03-01T00:00:00.000Z"), + heightCm: null, + locale: "en", + }, +}; + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.passkey.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.auditLog.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.userAchievement.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.userAchievement.createMany).mockResolvedValue({ + count: 0, + } as never); +}); + +describe("GET /api/gamification/achievements?format=ios", () => { + it("returns 401 when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await GET( + new NextRequest( + "http://localhost/api/gamification/achievements?format=ios", + ), + ); + expect(res.status).toBe(401); + }); + + it("returns a flat iOS-shaped achievement array", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await GET( + new NextRequest( + "http://localhost/api/gamification/achievements?format=ios", + ), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: Array<{ + id: string; + key: string; + title: string; + description: string; + iconName: string; + unlocked: boolean; + unlockedAt: string | null; + progress: number; + }>; + }; + expect(Array.isArray(body.data)).toBe(true); + expect(body.data.length).toBeGreaterThan(0); + const first = body.data[0]; + expect(first.id).toEqual(expect.any(String)); + expect(first.key).toEqual(expect.any(String)); + expect(first.title).toEqual(expect.any(String)); + expect(first.iconName).toEqual(expect.any(String)); + expect(first.progress).toBeGreaterThanOrEqual(0); + expect(first.progress).toBeLessThanOrEqual(1); + }); + + it("falls back to the legacy wrapped shape when no format is given", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await GET( + new NextRequest("http://localhost/api/gamification/achievements"), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { achievements: unknown[]; summary: unknown }; + }; + expect(body.data.achievements).toBeDefined(); + expect(body.data.summary).toBeDefined(); + }); +}); diff --git a/src/app/api/gamification/achievements/route.ts b/src/app/api/gamification/achievements/route.ts index 03603ffa9..c8f10099b 100644 --- a/src/app/api/gamification/achievements/route.ts +++ b/src/app/api/gamification/achievements/route.ts @@ -15,6 +15,20 @@ import { classifyPulse, } from "@/lib/analytics/classifications"; import { classifyIntakeTiming } from "@/lib/analytics/compliance"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import { resolveServerLocale } from "@/lib/i18n/server-locale"; +import type { NextRequest } from "next/server"; + +interface IosAchievement { + id: string; + key: string; + title: string; + description: string; + iconName: string; + unlocked: boolean; + unlockedAt: string | null; + progress: number; +} export const dynamic = "force-dynamic"; @@ -431,9 +445,14 @@ function getCompliance80DaySeries( return toDaySeries(greenDays); } -export const GET = apiHandler(async () => { +export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); - annotate({ action: { name: "gamification.achievements" } }); + const formatParam = request.nextUrl.searchParams.get("format"); + const isIosFormat = formatParam === "ios"; + annotate({ + action: { name: "gamification.achievements" }, + meta: { format: isIosFormat ? "ios" : "default" }, + }); const now = new Date(); const userId = user.id; @@ -742,5 +761,24 @@ export const GET = apiHandler(async () => { }); } + if (isIosFormat) { + const locale = await resolveServerLocale({ + request, + userLocale: user.locale, + }); + const t = getServerTranslator(locale); + const ios: IosAchievement[] = result.achievements.map((a) => ({ + id: a.id, + key: a.id, + title: t.t(a.titleKey), + description: t.t(a.descriptionKey), + iconName: a.icon, + unlocked: a.unlocked, + unlockedAt: a.completedAt, + progress: Math.max(0, Math.min(1, a.progressPercent / 100)), + })); + return apiSuccess(ios); + } + return apiSuccess(result); });