diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index eb8909465..2533625aa 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 0c7ac8244..2dfa2bc58 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -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: diff --git a/src/app/api/user/profile/__tests__/route.test.ts b/src/app/api/user/profile/__tests__/route.test.ts index f771581db..03567bd91 100644 --- a/src/app/api/user/profile/__tests__/route.test.ts +++ b/src/app/api/user/profile/__tests__/route.test.ts @@ -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; @@ -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 { + 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"); + }); +}); diff --git a/src/app/api/user/profile/route.ts b/src/app/api/user/profile/route.ts index d6e6f6a71..38e3dd77f 100644 --- a/src/app/api/user/profile/route.ts +++ b/src/app/api/user/profile/route.ts @@ -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, diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index f6b1dba67..b1fa0100d 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -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( diff --git a/src/lib/tz/resolver.ts b/src/lib/tz/resolver.ts index 1835432d5..6f3034dc5 100644 --- a/src/lib/tz/resolver.ts +++ b/src/lib/tz/resolver.ts @@ -144,6 +144,21 @@ export async function resolveServerDefaultTimezone(): Promise { 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 { + 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