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:
Marc-André Bombeck committed 2026-05-14 12:01:58 +02:00
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" },
});
});
});
+73
View File
@@ -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 });
});