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

+6
View File
@@ -47119,6 +47119,9 @@ components:
- type: "null"
timezone:
type: string
description: "The IANA zone the server cuts this account's days in, the same value `/api/auth/me` reports: the stored
zone, or the instance default when the stored value is not a usable IANA name (since v1.39.1; before, the
raw stored value). Always a valid IANA name, so sending it back in a PATCH is accepted."
timeFormat:
type: string
enum:
@@ -47231,6 +47234,9 @@ components:
- type: "null"
timezone:
type: string
description: "The IANA zone the server cuts this account's days in, the same value `/api/auth/me` reports: the stored
zone, or the instance default when the stored value is not a usable IANA name (since v1.39.1; before, the
raw stored value). Always a valid IANA name, so sending it back in a PATCH is accepted."
timeFormat:
type: string
enum:
+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,
+10 -2
View File
@@ -498,7 +498,11 @@ const profileResponse = z
gender: z.enum(["MALE", "FEMALE", "OTHER"]).nullable(),
heightCm: z.number().nullable(),
locale: z.string().nullable(),
timezone: z.string(),
timezone: z
.string()
.describe(
"The IANA zone the server cuts this account's days in, the same value `/api/auth/me` reports: the stored zone, or the instance default when the stored value is not a usable IANA name (since v1.39.1; before, the raw stored value). Always a valid IANA name, so sending it back in a PATCH is accepted.",
),
timeFormat: z
.enum(["AUTO", "H12", "H24"])
.describe(
@@ -542,7 +546,11 @@ const profileUpdateResponse = z
gender: z.enum(["MALE", "FEMALE", "OTHER"]).nullable(),
heightCm: z.number().nullable(),
locale: z.string().nullable(),
timezone: z.string(),
timezone: z
.string()
.describe(
"The IANA zone the server cuts this account's days in, the same value `/api/auth/me` reports: the stored zone, or the instance default when the stored value is not a usable IANA name (since v1.39.1; before, the raw stored value). Always a valid IANA name, so sending it back in a PATCH is accepted.",
),
timeFormat: z
.enum(["AUTO", "H12", "H24"])
.describe(
+15
View File
@@ -144,6 +144,21 @@ export async function resolveServerDefaultTimezone(): Promise<string> {
return tz;
}
/**
* The zone the server actually uses for a stored `User.timezone` value: the
* value itself when it is a usable IANA name, the instance default otherwise.
*
* Every response that reports an account's zone goes through this, so a
* client never buckets days by a value the server itself has set aside.
*/
export async function resolveStoredTimezone(
stored: string | null | undefined,
): Promise<string> {
return stored && isValidTimezone(stored)
? stored
: resolveServerDefaultTimezone();
}
/**
* Resolve the per-user display timezone. Reads `User.timezone`. Falls
* back to the server default and then to "Europe/Berlin". Cached for