mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
0cc73d3c88
commit
be112df972
6 files changed
+124
-11
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user