mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user