fix(profile): report the time zone the server uses on /api/user/profile

`/api/auth/me` has reported the resolved zone, the stored one or the
instance default when the stored value is unusable, while
`/api/user/profile` returned the raw column. A client reading the
profile could cut days differently from the server, and sending the
value back in a PATCH was refused.

Both reads, and the profile PATCH answer, now go through one
`resolveStoredTimezone`. The field stays a string and always holds a
valid IANA name. The web account editor already reads the resolved
zone from `/api/auth/me` and saves through the validating timezone
route, so it shows the zone in use and can only write a valid one.
This commit is contained in:
Marc-André Bombeck committed 2026-09-25 07:39:07 +02:00
1 parent 0cc73d3c88
commit be112df972
6 files changed
+124 -11

No files matched your search

+2 -7
View File
@@ -49,10 +49,7 @@ import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
import { buildAvatarUrl } from "@/lib/avatar";
import { decrypt } from "@/lib/crypto";
import { prisma } from "@/lib/db";
import {
isValidTimezone,
resolveServerDefaultTimezone,
} from "@/lib/tz/resolver";
import { resolveStoredTimezone } from "@/lib/tz/resolver";
import { isCycleEnabled } from "@/lib/cycle/gate";
import {
resolveModuleMap,
@@ -279,9 +276,7 @@ export const GET = apiHandler(async () => {
// The zone the server cuts this account's days in: the stored zone, or
// the instance default when the stored one is unusable. Clients bucket
// by this value, so it must be the resolved one, not the raw column.
timezone: isValidTimezone(user.timezone)
? user.timezone
: await resolveServerDefaultTimezone(),
timezone: await resolveStoredTimezone(user.timezone),
onboardingCompletedAt: user.onboardingCompletedAt,
onboardingTourCompleted: user.onboardingTourCompleted,
// v1.39 (C1) — the needs-based setup flow, published additively:
@@ -4,6 +4,7 @@ import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn(), update: vi.fn() },
appSettings: { findUnique: vi.fn() },
},
}));
@@ -60,6 +61,12 @@ const SESSION_OK = {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(resolveModuleMap).mockResolvedValue(MODULE_MAP as never);
// The operator's default zone, which the server falls back to when a
// stored one is unusable. Distinct from the built-in default so a test can
// tell the resolver ran.
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
defaultUserTimezone: "America/Chicago",
} as never);
});
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
@@ -438,3 +445,81 @@ describe("PATCH /api/user/profile", () => {
]);
});
});
/**
* `/api/auth/me` has returned the zone the server actually cuts days in —
* the stored one, or the instance default when the stored value is unusable
* — while this alias returned the raw column. A client reading this endpoint
* could bucket a day differently from the server. Both now go through the
* same resolution.
*/
describe("/api/user/profile — timezone is the resolved zone", () => {
function profileRow(timezone: string | null) {
return {
username: "testuser",
displayName: null,
email: null,
dateOfBirth: null,
gender: null,
heightCm: null,
locale: null,
timezone,
moodReminderEnabled: false,
fullName: null,
insurerName: null,
insurerIkNumber: null,
insuranceNumberEncrypted: null,
};
}
async function getZone(stored: string | null): Promise<string> {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(
profileRow(stored) as never,
);
const res = await callGet(makeGetReq());
expect(res.status).toBe(200);
return ((await res.json()) as { data: { timezone: string } }).data.timezone;
}
it("returns a usable stored zone as stored", async () => {
expect(await getZone("Asia/Tokyo")).toBe("Asia/Tokyo");
});
it("returns the instance default in place of a bare offset", async () => {
expect(await getZone("+05:30")).toBe("America/Chicago");
});
it("returns the instance default in place of a name no runtime knows", async () => {
expect(await getZone("Mars/Olympus_Mons")).toBe("America/Chicago");
});
it("returns the instance default when the row is missing", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
const res = await callGet(makeGetReq());
const body = (await res.json()) as { data: { timezone: string } };
expect(body.data.timezone).toBe("America/Chicago");
});
it("answers a PATCH that leaves an unusable zone untouched with the resolved one", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
vi.mocked(prisma.user.update).mockResolvedValue({
...profileRow("+05:30"),
id: "user-1",
role: "USER",
displayName: "Alex T.",
} as never);
const res = await PATCH(
new NextRequest("http://localhost/api/user/profile", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ displayName: "Alex T." }),
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { timezone: string } };
expect(body.data.timezone).toBe("America/Chicago");
});
});
+6 -2
View File
@@ -20,6 +20,7 @@ import { applyProfileUpdate } from "@/lib/auth/profile-update";
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import { resolveModuleMap } from "@/lib/modules/gate";
import { resolveStoredTimezone } from "@/lib/tz/resolver";
export const dynamic = "force-dynamic";
@@ -77,7 +78,10 @@ export const GET = apiHandler(async () => {
gender: dbUser?.gender ?? null,
heightCm: dbUser?.heightCm ?? null,
locale: dbUser?.locale ?? null,
timezone: dbUser?.timezone ?? "Europe/Berlin",
// The zone the server cuts this account's days in, exactly as
// `/api/auth/me` reports it: the stored zone, or the instance default
// when the stored value is unusable. Never the raw column.
timezone: await resolveStoredTimezone(dbUser?.timezone),
timeFormat: dbUser?.timeFormat ?? "AUTO",
dateFormat: dbUser?.dateFormat ?? "AUTO",
moodReminderEnabled: dbUser?.moodReminderEnabled ?? false,
@@ -118,7 +122,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
gender: result.user.gender,
heightCm: result.user.heightCm,
locale: result.user.locale,
timezone: result.user.timezone,
timezone: await resolveStoredTimezone(result.user.timezone),
timeFormat: result.user.timeFormat,
dateFormat: result.user.dateFormat,
moodReminderEnabled: result.user.moodReminderEnabled,