mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
refactor(auth/profile): extract validation/persistence into shared helper
`PUT /api/auth/profile` now delegates to `applyProfileUpdate()` in `src/lib/auth/profile-update.ts`. Validation, email-collision check, update, audit-log — all unchanged, just lifted out of the route handler so a second consumer can reuse it without forking the path. Adds `GET/PATCH /api/user/profile` as that second consumer, exposing the same shape under a slightly different verb that better suits headless clients (PATCH partial updates, GET reads). Both endpoints touch the same `applyProfileUpdate` so any future field stays in sync. Existing PUT response shape is unchanged; the existing PUT consumer (web settings page) is unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
f83a68d78e
commit
98e209b6f7
4 files changed
+328
-44
No files matched your search
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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<Response>;
|
||||
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",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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<typeof extendedProfileSchema>;
|
||||
|
||||
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<ApplyProfileResult | ApplyProfileError> {
|
||||
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<string, unknown> = {};
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user