From 0772f06d25ed935c46cc8f7e7b7c31da9b67c1de Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Tue, 13 Oct 2026 14:03:44 -0400 Subject: [PATCH] fix(tz): JWT context stub carries the user's timezone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The middleware's per-request user lookup (ghost-session guard) fetched the full row and discarded it, then built the context stub without a timezone — so every server-side conversion via the context user fell back to UTC no matter what the handlers did. The stub now carries Timezone from that same lookup (fetchLiveUser). Live-verified: progress page renders 02:03 PM for a 14:03 New York instant; an analytics session at 02:00 UTC buckets to 10-12 (10pm NY the previous day) instead of 10-13. --- internal/router/router.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/internal/router/router.go b/internal/router/router.go index fca5c84b..85b87d29 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -118,8 +118,11 @@ func createJWTMiddleware(cfg *Config) echo.MiddlewareFunc { // full access until token expiry before this check). A // primary-key lookup per request — instant revocation, no TTL // semantics; the guard also covers the SSR groups that used to - // chain ensureUserExistsMiddleware. - if !userExists(cfg, userUUID) { + // chain ensureUserExistsMiddleware. The fetched row also feeds + // the context stub: the user's timezone must travel with every + // request so server-side time rendering converts per-user. + liveUser, exists := fetchLiveUser(cfg, userUUID) + if !exists { log.Printf("Ghost session rejected: user %s no longer exists", userIDStr) c.SetCookie(&http.Cookie{ Name: "token", @@ -144,6 +147,7 @@ func createJWTMiddleware(cfg *Config) echo.MiddlewareFunc { Email: claims["user_email"].(string), Username: claims["user_username"].(string), Role: claims["user_role"].(string), + Timezone: liveUser.Timezone, }) return nil }, @@ -172,6 +176,13 @@ func userExists(cfg *Config, id uuid.UUID) bool { return err == nil } +// fetchLiveUser loads the full user row for the JWT stub — the ghost-session +// guard and the timezone travel together on this one per-request lookup. +func fetchLiveUser(cfg *Config, id uuid.UUID) (database.GetUserRow, bool) { + u, err := cfg.Queries.GetUser(context.Background(), uuidToPGType(id)) + return u, err == nil +} + // wantsHTML determines if the request expects HTML response func wantsHTML(header http.Header) bool { // Check Accept header