diff --git a/src/app/api/auth/profile/route.ts b/src/app/api/auth/profile/route.ts index 56bb228d8..62c806960 100644 --- a/src/app/api/auth/profile/route.ts +++ b/src/app/api/auth/profile/route.ts @@ -1,62 +1,30 @@ -import { prisma } from "@/lib/db"; -import { profileSchema } from "@/lib/validations/auth"; -import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response"; import { NextRequest } from "next/server"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { applyProfileUpdate } from "@/lib/auth/profile-update"; export const PUT = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); const { data: body, error: jsonError } = await safeJson(request); - if (jsonError) return jsonError; - const parsed = profileSchema.safeParse(body); - if (!parsed.success) { - return apiError(parsed.error.issues[0].message, 422); + const result = await applyProfileUpdate(user.id, body, getClientIp(request)); + if (!result.ok) { + return apiError(result.message, result.status); } - const data = parsed.data; - const normalizedEmail = data.email ? data.email.trim().toLowerCase() : null; - - if (data.email !== undefined && normalizedEmail) { - const existingUser = await prisma.user.findUnique({ - where: { email: normalizedEmail }, - select: { id: true }, - }); - - if (existingUser && existingUser.id !== user.id) { - return apiError("Email already in use", 409); - } - } - - const updatedUser = await prisma.user.update({ - where: { id: user.id }, - data: { - ...(data.email !== undefined ? { email: normalizedEmail } : {}), - heightCm: data.heightCm ?? null, - dateOfBirth: data.dateOfBirth ? new Date(data.dateOfBirth) : null, - ...(data.gender !== undefined ? { gender: data.gender } : {}), - }, - }); - - await auditLog("profile.update", { - userId: updatedUser.id, - ipAddress: getClientIp(request), - }); - annotate({ action: { name: "auth.profile.update" } }); return apiSuccess({ - id: updatedUser.id, - username: updatedUser.username, - email: updatedUser.email, - role: updatedUser.role, - heightCm: updatedUser.heightCm, - dateOfBirth: updatedUser.dateOfBirth, - gender: updatedUser.gender, - timezone: updatedUser.timezone, + id: result.user.id, + username: result.user.username, + email: result.user.email, + role: result.user.role, + heightCm: result.user.heightCm, + dateOfBirth: result.user.dateOfBirth, + gender: result.user.gender, + timezone: result.user.timezone, }); }); diff --git a/src/app/api/user/profile/__tests__/route.test.ts b/src/app/api/user/profile/__tests__/route.test.ts new file mode 100644 index 000000000..af1d9078d --- /dev/null +++ b/src/app/api/user/profile/__tests__/route.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn(), update: vi.fn() }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +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, PATCH } 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 }, +}; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +const callGet = GET as unknown as (req: NextRequest) => Promise; +function makeGetReq(): NextRequest { + return new NextRequest("http://localhost/api/user/profile"); +} + +describe("GET /api/user/profile", () => { + it("returns 401 when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await callGet(makeGetReq()); + expect(res.status).toBe(401); + }); + + it("returns flattened iOS-style fields", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + username: "marc", + displayName: "Marc B.", + email: "marc@example.com", + dateOfBirth: new Date("1985-03-12T00:00:00.000Z"), + gender: "MALE", + heightCm: 180, + locale: "de", + timezone: "Europe/Berlin", + } as never); + + const res = await callGet(makeGetReq()); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + username: string; + displayName: string | null; + heightCm: number; + locale: string | null; + }; + }; + expect(body.data.username).toBe("marc"); + expect(body.data.displayName).toBe("Marc B."); + expect(body.data.heightCm).toBe(180); + expect(body.data.locale).toBe("de"); + }); +}); + +describe("PATCH /api/user/profile", () => { + function req(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/user/profile", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + } + + it("returns 401 when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await PATCH(req({ displayName: "Test" })); + expect(res.status).toBe(401); + }); + + it("returns 422 for invalid heightCm", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await PATCH(req({ heightCm: 9999 })); + expect(res.status).toBe(422); + }); + + it("persists displayName + locale via shared helper", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); + vi.mocked(prisma.user.update).mockResolvedValue({ + id: "user-1", + username: "marc", + displayName: "Marc B.", + email: null, + role: "USER", + heightCm: null, + dateOfBirth: null, + gender: null, + timezone: "Europe/Berlin", + locale: "de", + } as never); + + const res = await PATCH(req({ displayName: "Marc B.", locale: "de" })); + expect(res.status).toBe(200); + expect(prisma.user.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "user-1" }, + data: expect.objectContaining({ + displayName: "Marc B.", + locale: "de", + }), + }), + ); + }); +}); diff --git a/src/app/api/user/profile/route.ts b/src/app/api/user/profile/route.ts new file mode 100644 index 000000000..6101276b2 --- /dev/null +++ b/src/app/api/user/profile/route.ts @@ -0,0 +1,71 @@ +/** + * iOS-friendly user profile endpoint. + * + * GET → flattened profile fields (camelCase) for the native client. + * Aliased over the same data exposed by `/api/auth/me`. + * PATCH → delegates to the shared `applyProfileUpdate` helper used by + * `/api/auth/profile` PUT — same validation, same audit log. + */ +import { NextRequest } from "next/server"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess, getClientIp, safeJson } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { applyProfileUpdate } from "@/lib/auth/profile-update"; +import { prisma } from "@/lib/db"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "user.profile.get" } }); + + const dbUser = await prisma.user.findUnique({ + where: { id: user.id }, + select: { + username: true, + displayName: true, + email: true, + dateOfBirth: true, + gender: true, + heightCm: true, + locale: true, + timezone: true, + }, + }); + + return apiSuccess({ + username: dbUser?.username ?? user.username, + displayName: dbUser?.displayName ?? null, + email: dbUser?.email ?? null, + dateOfBirth: dbUser?.dateOfBirth?.toISOString() ?? null, + gender: dbUser?.gender ?? null, + heightCm: dbUser?.heightCm ?? null, + locale: dbUser?.locale ?? null, + timezone: dbUser?.timezone ?? "Europe/Berlin", + }); +}); + +export const PATCH = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const { data: body, error } = await safeJson(request); + if (error) return error; + + const result = await applyProfileUpdate(user.id, body, getClientIp(request)); + if (!result.ok) { + return apiError(result.message, result.status); + } + + annotate({ action: { name: "user.profile.update" } }); + + return apiSuccess({ + username: result.user.username, + displayName: result.user.displayName, + email: result.user.email, + dateOfBirth: result.user.dateOfBirth?.toISOString() ?? null, + gender: result.user.gender, + heightCm: result.user.heightCm, + locale: result.user.locale, + timezone: result.user.timezone, + }); +}); diff --git a/src/lib/auth/profile-update.ts b/src/lib/auth/profile-update.ts new file mode 100644 index 000000000..03c0de7c3 --- /dev/null +++ b/src/lib/auth/profile-update.ts @@ -0,0 +1,114 @@ +/** + * Shared profile-update logic. The web client hits `/api/auth/profile` PUT + * and the iOS client hits `/api/user/profile` PATCH — both funnel through + * `applyProfileUpdate` so we don't fork the validation/audit path. + */ +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { profileSchema } from "@/lib/validations/auth"; +import { z } from "zod/v4"; + +const extendedProfileSchema = profileSchema.extend({ + displayName: z.string().min(1).max(80).nullable().optional(), + locale: z.enum(["de", "en"]).nullable().optional(), + timezone: z.string().min(1).max(64).optional(), +}); + +export type ApplyProfileInput = z.infer; + +export interface ApplyProfileResult { + ok: true; + user: { + id: string; + username: string; + displayName: string | null; + email: string | null; + role: string; + heightCm: number | null; + dateOfBirth: Date | null; + gender: string | null; + timezone: string; + locale: string | null; + }; +} + +export interface ApplyProfileError { + ok: false; + status: number; + message: string; +} + +/** + * Validate and persist profile updates for `userId`. Returns either an + * `ApplyProfileResult` on success or `ApplyProfileError` with a status + * code so callers can shape the wire response themselves. + */ +export async function applyProfileUpdate( + userId: string, + body: unknown, + ipAddress?: string | null, +): Promise { + const parsed = extendedProfileSchema.safeParse(body); + if (!parsed.success) { + return { + ok: false, + status: 422, + message: parsed.error.issues[0]?.message ?? "Validation error", + }; + } + + const data = parsed.data; + const normalizedEmail = data.email ? data.email.trim().toLowerCase() : null; + + if (data.email !== undefined && normalizedEmail) { + const existing = await prisma.user.findUnique({ + where: { email: normalizedEmail }, + select: { id: true }, + }); + if (existing && existing.id !== userId) { + return { ok: false, status: 409, message: "Email already in use" }; + } + } + + const updates: Record = {}; + if (data.email !== undefined) updates.email = normalizedEmail; + if (data.heightCm !== undefined) updates.heightCm = data.heightCm ?? null; + if (data.dateOfBirth !== undefined) { + updates.dateOfBirth = data.dateOfBirth ? new Date(data.dateOfBirth) : null; + } + if (data.gender !== undefined) updates.gender = data.gender; + if (data.displayName !== undefined) { + updates.displayName = + data.displayName === null || data.displayName === "" + ? null + : data.displayName.trim(); + } + if (data.locale !== undefined) updates.locale = data.locale; + if (data.timezone !== undefined) updates.timezone = data.timezone; + + const updatedUser = await prisma.user.update({ + where: { id: userId }, + data: updates, + }); + + await auditLog("profile.update", { + userId: updatedUser.id, + ipAddress: ipAddress ?? null, + }); + + return { + ok: true, + user: { + id: updatedUser.id, + username: updatedUser.username, + displayName: updatedUser.displayName, + email: updatedUser.email, + role: updatedUser.role, + heightCm: updatedUser.heightCm, + dateOfBirth: updatedUser.dateOfBirth, + gender: updatedUser.gender, + timezone: updatedUser.timezone, + locale: updatedUser.locale, + }, + }; +}