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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-03 21:53:21 +02:00
1 parent 1f5ad1729d
commit 83251848e4
2 files changed
+154 -2

No files matched your search

@@ -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();
});
});
+40 -2
View File
@@ -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);
});