mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
feat(profile): timezone picker on account settings page
v1.4.25 W7 phase 4 — proposal §5.
Adds a per-user timezone picker to /settings#account between
date-of-birth and language. The control is a native <select> over
Intl.supportedValuesOf('timeZone') with a 'use browser timezone'
button that pre-fills detectBrowserTimezone(). Older engines without
Intl.supportedValuesOf fall back to a free-text input — every Chrome
99+/Safari 15.4+/Firefox 99+ ships the API so the fallback is
defensive only.
Form save uses Promise.all over two PUTs:
- /api/auth/profile (email, height, dob, gender)
- /api/auth/me/timezone (single field, dedicated route)
The split keeps the timezone validation + resolver cache
invalidation on a clean path; profile errors don't poison the
timezone write and vice versa.
The new route PUT /api/auth/me/timezone:
- requireAuth gates the cookie + Bearer paths
- 422 on any string the runtime's Intl.DateTimeFormat rejects
- 200 on success, returns the persisted value
- audits 'user.timezone.update' with previous + next
- invalidates the resolver cache so the next read sees the new
value within one tick instead of waiting 60s for the TTL
i18n: 4 new keys (timezone, timezoneHint, timezoneDetect,
timezoneInvalid) in messages/de.json + messages/en.json. Hint copy
follows proposal §5: 'Used for chart axis labels, reminder times,
export timestamps, and AI Coach context. Stored data is unchanged.'
Tests: 6-case suite on the new route covers auth, valid + invalid
zones, empty / missing field, and whitespace trimming.
This commit is contained in:
1 parent
75cf0b60dc
commit
65ce9fd4c8
6 files changed
+359
-11
No files matched your search
@@ -0,0 +1,114 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
appSettings: { findUnique: 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 { PUT } 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 },
|
||||
};
|
||||
|
||||
function mkReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/auth/me/timezone", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("PUT /api/auth/me/timezone", () => {
|
||||
it("rejects unauthenticated requests with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await PUT(mkReq({ timezone: "Europe/Berlin" }));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("accepts a valid IANA zone and writes it to the user", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
timezone: "Europe/Berlin",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({
|
||||
timezone: "Pacific/Auckland",
|
||||
} as never);
|
||||
|
||||
const res = await PUT(mkReq({ timezone: "Pacific/Auckland" }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { timezone: "Pacific/Auckland" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an invalid timezone with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await PUT(mkReq({ timezone: "Mars/Olympus" }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an empty timezone with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await PUT(mkReq({ timezone: "" }));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("rejects a missing timezone field with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await PUT(mkReq({}));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("trims whitespace before validating", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
timezone: "Europe/Berlin",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({
|
||||
timezone: "Asia/Tokyo",
|
||||
} as never);
|
||||
const res = await PUT(mkReq({ timezone: " Asia/Tokyo " }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { timezone: "Asia/Tokyo" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* PUT /api/auth/me/timezone
|
||||
*
|
||||
* v1.4.25 W7 — per-user timezone setter. Lifts the timezone write
|
||||
* out of `PUT /api/auth/profile` so the picker UI can post the
|
||||
* single field without round-tripping email/heightCm/etc, and so
|
||||
* the change pipeline (validate → write → invalidate-cache) is
|
||||
* isolated from the heavier profile patch.
|
||||
*
|
||||
* Auth: cookie session OR Bearer token.
|
||||
* Audit: `user.timezone.update` with the old + new zone.
|
||||
*
|
||||
* Validation: the body is `{ "timezone": "Europe/Berlin" }`. We
|
||||
* accept any string the runtime's `Intl.DateTimeFormat` constructor
|
||||
* accepts — i.e. every IANA zone the active Node engine knows about.
|
||||
* Rejecting unknown zones at the API surface stops a malformed
|
||||
* client from poisoning the column with a value the resolver
|
||||
* would later have to defend against.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserTimezone, isValidTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "user.timezone.update" } });
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const tz =
|
||||
body && typeof (body as { timezone?: unknown }).timezone === "string"
|
||||
? ((body as { timezone: string }).timezone || "").trim()
|
||||
: "";
|
||||
|
||||
if (!isValidTimezone(tz)) {
|
||||
return apiError("Not a valid IANA timezone.", 422);
|
||||
}
|
||||
|
||||
const before = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { timezone: true },
|
||||
});
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { timezone: tz },
|
||||
});
|
||||
|
||||
invalidateUserTimezone(user.id);
|
||||
|
||||
await auditLog("user.timezone.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
previous: before?.timezone ?? null,
|
||||
next: tz,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
timezone_previous: before?.timezone ?? null,
|
||||
timezone_next: tz,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ timezone: tz });
|
||||
});
|
||||
Reference in new issue
Block a user