From 230da7bdcb7c69b5aa767c24092006e2ed7a9f39 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 13 May 2026 11:54:45 -0400 Subject: [PATCH] feat(users): timezone column + PUT /api/me/timezone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema + endpoint scaffolding for #392 Half B (per-user timezone scheduling). Adds two columns to the users table: - timezone text NOT NULL DEFAULT 'UTC' (IANA name) - timezone_updated_at timestamptz (nullable; populated on each PUT) PUT /api/me/timezone validates the IANA value via time.LoadLocation and writes the row. No scheduler integration yet — the scheduler struct lands in the next commit and the handler-side Refresh call in the commit after. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/api/api.go | 1 + internal/api/me_timezone.go | 60 +++++++++++ internal/api/me_timezone_test.go | 100 +++++++++++++++++ internal/db/dbq/models.go | 2 + internal/db/dbq/users.sql.go | 102 ++++++++++++++++-- .../migrations/0026_users_timezone.down.sql | 3 + .../db/migrations/0026_users_timezone.up.sql | 8 ++ internal/db/queries/users.sql | 21 ++++ 8 files changed, 286 insertions(+), 11 deletions(-) create mode 100644 internal/api/me_timezone.go create mode 100644 internal/api/me_timezone_test.go create mode 100644 internal/db/migrations/0026_users_timezone.down.sql create mode 100644 internal/db/migrations/0026_users_timezone.up.sql diff --git a/internal/api/api.go b/internal/api/api.go index 32de80c8..dafbea68 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -64,6 +64,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev authed.Get("/me/history", h.handleGetMyHistory) authed.Put("/me/password", h.handleChangePassword) authed.Put("/me/profile", h.handleUpdateMyProfile) + authed.Put("/me/timezone", h.handlePutTimezone) authed.Get("/me/api-token", h.handleGetMyAPIToken) authed.Post("/me/api-token", h.handleRegenerateMyAPIToken) diff --git a/internal/api/me_timezone.go b/internal/api/me_timezone.go new file mode 100644 index 00000000..8f5cc5fa --- /dev/null +++ b/internal/api/me_timezone.go @@ -0,0 +1,60 @@ +// PUT /api/me/timezone — accept the client's IANA timezone and store +// it on the user row. The system-playlist scheduler (#392 Half B) +// reads this column to fire each user's daily 03:00 build in their +// local time. +// +// Scheduler.Refresh(ctx, userID) is invoked in a later commit (Task 3) +// so a timezone change takes effect synchronously rather than waiting +// for the hourly reconciliation pass. + +package api + +import ( + "encoding/json" + "net/http" + "time" + + "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// putTimezoneBody is the JSON body for PUT /api/me/timezone. +type putTimezoneBody struct { + Timezone string `json:"timezone"` +} + +func (h *handlers) handlePutTimezone(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeErr(w, apierror.Unauthorized("unauthorized", "authentication required")) + return + } + + var body putTimezoneBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeErr(w, apierror.BadRequest("bad_body", "invalid JSON body")) + return + } + if body.Timezone == "" { + writeErr(w, apierror.BadRequest("bad_body", "timezone is required")) + return + } + if _, err := time.LoadLocation(body.Timezone); err != nil { + writeErr(w, apierror.BadRequest("invalid_timezone", + "timezone must be a valid IANA name (e.g. America/New_York)")) + return + } + + q := dbq.New(h.pool) + if err := q.UpdateUserTimezone(r.Context(), dbq.UpdateUserTimezoneParams{ + ID: user.ID, + Timezone: body.Timezone, + }); err != nil { + h.logger.Error("api: update timezone", "err", err) + writeErr(w, apierror.InternalMsg("update failed", err)) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/me_timezone_test.go b/internal/api/me_timezone_test.go new file mode 100644 index 00000000..0fae0852 --- /dev/null +++ b/internal/api/me_timezone_test.go @@ -0,0 +1,100 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/go-chi/chi/v5" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +func newMeTimezoneRouter(h *handlers) chi.Router { + r := chi.NewRouter() + r.Put("/api/me/timezone", h.handlePutTimezone) + return r +} + +func TestPutTimezone_ValidIANA(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, pool := testHandlers(t) + user := seedUser(t, pool, "tz1", "irrelevant1", false) + + body := bytes.NewBufferString(`{"timezone":"America/New_York"}`) + req := httptest.NewRequest(http.MethodPut, "/api/me/timezone", body) + req.Header.Set("Content-Type", "application/json") + req = withUser(req, user) + rec := httptest.NewRecorder() + newMeTimezoneRouter(h).ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204; body=%s", rec.Code, rec.Body.String()) + } + + // Verify the DB row was updated. + got, err := dbq.New(pool).GetUserByID(context.Background(), user.ID) + if err != nil { + t.Fatalf("GetUserByID: %v", err) + } + if got.Timezone != "America/New_York" { + t.Errorf("timezone = %q, want %q", got.Timezone, "America/New_York") + } + if !got.TimezoneUpdatedAt.Valid { + t.Errorf("expected timezone_updated_at to be set after PUT") + } +} + +func TestPutTimezone_InvalidIANA(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, pool := testHandlers(t) + user := seedUser(t, pool, "tz2", "irrelevant1", false) + + body := bytes.NewBufferString(`{"timezone":"Not/A/Real/Zone"}`) + req := httptest.NewRequest(http.MethodPut, "/api/me/timezone", body) + req.Header.Set("Content-Type", "application/json") + req = withUser(req, user) + rec := httptest.NewRecorder() + newMeTimezoneRouter(h).ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } + + var errResp struct { + Code string `json:"code"` + } + if err := json.NewDecoder(rec.Body).Decode(&errResp); err != nil { + t.Fatalf("decode err response: %v", err) + } + if errResp.Code != "invalid_timezone" { + t.Errorf("error code = %q, want invalid_timezone", errResp.Code) + } +} + +func TestPutTimezone_EmptyTimezone(t *testing.T) { + if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" { + t.Skip("MINSTREL_TEST_DATABASE_URL not set") + } + h, pool := testHandlers(t) + user := seedUser(t, pool, "tz3", "irrelevant1", false) + + body := bytes.NewBufferString(`{"timezone":""}`) + req := httptest.NewRequest(http.MethodPut, "/api/me/timezone", body) + req.Header.Set("Content-Type", "application/json") + req = withUser(req, user) + rec := httptest.NewRecorder() + newMeTimezoneRouter(h).ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } +} diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go index 2a50b419..f99894a5 100644 --- a/internal/db/dbq/models.go +++ b/internal/db/dbq/models.go @@ -522,6 +522,8 @@ type User struct { DisplayName *string AutoApproveRequests bool Email *string + Timezone string + TimezoneUpdatedAt pgtype.Timestamptz } type UserInvite struct { diff --git a/internal/db/dbq/users.sql.go b/internal/db/dbq/users.sql.go index 45ceb1ba..3f1b9722 100644 --- a/internal/db/dbq/users.sql.go +++ b/internal/db/dbq/users.sql.go @@ -54,7 +54,7 @@ func (q *Queries) CountUsers(ctx context.Context) (int64, error) { const createUser = `-- name: CreateUser :one INSERT INTO users (username, password_hash, api_token, is_admin, display_name) VALUES ($1, $2, $3, $4, $5) -RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email +RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at ` type CreateUserParams struct { @@ -87,6 +87,8 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e &i.DisplayName, &i.AutoApproveRequests, &i.Email, + &i.Timezone, + &i.TimezoneUpdatedAt, ) return i, err } @@ -94,7 +96,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e const createUserAdmin = `-- name: CreateUserAdmin :one INSERT INTO users (username, password_hash, api_token, is_admin, display_name) VALUES ($1, $2, $3, $4, $5) -RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email +RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at ` type CreateUserAdminParams struct { @@ -130,6 +132,8 @@ func (q *Queries) CreateUserAdmin(ctx context.Context, arg CreateUserAdminParams &i.DisplayName, &i.AutoApproveRequests, &i.Email, + &i.Timezone, + &i.TimezoneUpdatedAt, ) return i, err } @@ -141,7 +145,7 @@ VALUES ( (SELECT NOT EXISTS (SELECT 1 FROM users)), $4 ) -RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email +RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at ` type CreateUserFirstAdminRaceParams struct { @@ -185,6 +189,8 @@ func (q *Queries) CreateUserFirstAdminRace(ctx context.Context, arg CreateUserFi &i.DisplayName, &i.AutoApproveRequests, &i.Email, + &i.Timezone, + &i.TimezoneUpdatedAt, ) return i, err } @@ -231,7 +237,7 @@ func (q *Queries) GetListenBrainzConfig(ctx context.Context, id pgtype.UUID) (Ge } const getUserByAPIToken = `-- name: GetUserByAPIToken :one -SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email FROM users WHERE api_token = $1 +SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at FROM users WHERE api_token = $1 ` func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User, error) { @@ -250,12 +256,14 @@ func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User, &i.DisplayName, &i.AutoApproveRequests, &i.Email, + &i.Timezone, + &i.TimezoneUpdatedAt, ) return i, err } const getUserByEmail = `-- name: GetUserByEmail :one -SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email FROM users WHERE lower(email) = lower($1) +SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at FROM users WHERE lower(email) = lower($1) ` // Used by forgot-password lookup. Lowercase comparison both sides @@ -277,12 +285,14 @@ func (q *Queries) GetUserByEmail(ctx context.Context, lower string) (User, error &i.DisplayName, &i.AutoApproveRequests, &i.Email, + &i.Timezone, + &i.TimezoneUpdatedAt, ) return i, err } const getUserByID = `-- name: GetUserByID :one -SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email FROM users WHERE id = $1 +SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at FROM users WHERE id = $1 ` func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error) { @@ -301,12 +311,14 @@ func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error) &i.DisplayName, &i.AutoApproveRequests, &i.Email, + &i.Timezone, + &i.TimezoneUpdatedAt, ) return i, err } const getUserByUsername = `-- name: GetUserByUsername :one -SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email FROM users WHERE username = $1 +SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at FROM users WHERE username = $1 ` func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) { @@ -325,10 +337,50 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, &i.DisplayName, &i.AutoApproveRequests, &i.Email, + &i.Timezone, + &i.TimezoneUpdatedAt, ) return i, err } +const listActiveUsersWithTimezones = `-- name: ListActiveUsersWithTimezones :many +SELECT u.id, u.timezone FROM users u + WHERE EXISTS ( + SELECT 1 FROM play_events pe + WHERE pe.user_id = u.id + AND pe.started_at > now() - INTERVAL '7 days' + ) +` + +type ListActiveUsersWithTimezonesRow struct { + ID pgtype.UUID + Timezone string +} + +// Returns (id, timezone) for every user with a play in the last 7 +// days. The system-playlist scheduler iterates this list at startup +// and during its hourly reconciliation to discover newly-active / +// no-longer-active users. +func (q *Queries) ListActiveUsersWithTimezones(ctx context.Context) ([]ListActiveUsersWithTimezonesRow, error) { + rows, err := q.db.Query(ctx, listActiveUsersWithTimezones) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListActiveUsersWithTimezonesRow + for rows.Next() { + var i ListActiveUsersWithTimezonesRow + if err := rows.Scan(&i.ID, &i.Timezone); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listUsers = `-- name: ListUsers :many SELECT id, username, display_name, is_admin, auto_approve_requests, created_at FROM users @@ -374,7 +426,7 @@ func (q *Queries) ListUsers(ctx context.Context) ([]ListUsersRow, error) { const regenerateApiToken = `-- name: RegenerateApiToken :one UPDATE users SET api_token = $2 WHERE id = $1 -RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email +RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at ` type RegenerateApiTokenParams struct { @@ -400,6 +452,8 @@ func (q *Queries) RegenerateApiToken(ctx context.Context, arg RegenerateApiToken &i.DisplayName, &i.AutoApproveRequests, &i.Email, + &i.Timezone, + &i.TimezoneUpdatedAt, ) return i, err } @@ -477,7 +531,7 @@ const updateUserAdmin = `-- name: UpdateUserAdmin :one UPDATE users SET is_admin = $2 WHERE id = $1 -RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email +RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at ` type UpdateUserAdminParams struct { @@ -503,6 +557,8 @@ func (q *Queries) UpdateUserAdmin(ctx context.Context, arg UpdateUserAdminParams &i.DisplayName, &i.AutoApproveRequests, &i.Email, + &i.Timezone, + &i.TimezoneUpdatedAt, ) return i, err } @@ -511,7 +567,7 @@ const updateUserAutoApprove = `-- name: UpdateUserAutoApprove :one UPDATE users SET auto_approve_requests = $2 WHERE id = $1 -RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email +RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at ` type UpdateUserAutoApproveParams struct { @@ -536,6 +592,8 @@ func (q *Queries) UpdateUserAutoApprove(ctx context.Context, arg UpdateUserAutoA &i.DisplayName, &i.AutoApproveRequests, &i.Email, + &i.Timezone, + &i.TimezoneUpdatedAt, ) return i, err } @@ -545,7 +603,7 @@ UPDATE users SET display_name = $2, email = $3 WHERE id = $1 -RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email +RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email, timezone, timezone_updated_at ` type UpdateUserProfileParams struct { @@ -572,6 +630,28 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa &i.DisplayName, &i.AutoApproveRequests, &i.Email, + &i.Timezone, + &i.TimezoneUpdatedAt, ) return i, err } + +const updateUserTimezone = `-- name: UpdateUserTimezone :exec +UPDATE users + SET timezone = $2, + timezone_updated_at = now() + WHERE id = $1 +` + +type UpdateUserTimezoneParams struct { + ID pgtype.UUID + Timezone string +} + +// Sets the user's IANA timezone and bumps timezone_updated_at. The +// handler validates the timezone string via time.LoadLocation before +// calling this; the DB does not re-validate. +func (q *Queries) UpdateUserTimezone(ctx context.Context, arg UpdateUserTimezoneParams) error { + _, err := q.db.Exec(ctx, updateUserTimezone, arg.ID, arg.Timezone) + return err +} diff --git a/internal/db/migrations/0026_users_timezone.down.sql b/internal/db/migrations/0026_users_timezone.down.sql new file mode 100644 index 00000000..6752f9d1 --- /dev/null +++ b/internal/db/migrations/0026_users_timezone.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE users + DROP COLUMN timezone, + DROP COLUMN timezone_updated_at; diff --git a/internal/db/migrations/0026_users_timezone.up.sql b/internal/db/migrations/0026_users_timezone.up.sql new file mode 100644 index 00000000..52edc73d --- /dev/null +++ b/internal/db/migrations/0026_users_timezone.up.sql @@ -0,0 +1,8 @@ +-- 0026_users_timezone.up.sql — per-user timezone for the system-playlist +-- scheduler (#392 Half B). Default 'UTC' so existing rows + brand-new +-- users have a safe value before their first client check-in via +-- PUT /api/me/timezone. The scheduler reads this column to decide +-- which IANA zone to anchor each user's daily 03:00 build to. +ALTER TABLE users + ADD COLUMN timezone text NOT NULL DEFAULT 'UTC', + ADD COLUMN timezone_updated_at timestamptz; diff --git a/internal/db/queries/users.sql b/internal/db/queries/users.sql index c8b81315..800e1b7a 100644 --- a/internal/db/queries/users.sql +++ b/internal/db/queries/users.sql @@ -142,3 +142,24 @@ RETURNING *; -- to keep the unique index happy and to make the lookup -- case-insensitive. SELECT * FROM users WHERE lower(email) = lower($1); + +-- name: UpdateUserTimezone :exec +-- Sets the user's IANA timezone and bumps timezone_updated_at. The +-- handler validates the timezone string via time.LoadLocation before +-- calling this; the DB does not re-validate. +UPDATE users + SET timezone = $2, + timezone_updated_at = now() + WHERE id = $1; + +-- name: ListActiveUsersWithTimezones :many +-- Returns (id, timezone) for every user with a play in the last 7 +-- days. The system-playlist scheduler iterates this list at startup +-- and during its hourly reconciliation to discover newly-active / +-- no-longer-active users. +SELECT u.id, u.timezone FROM users u + WHERE EXISTS ( + SELECT 1 FROM play_events pe + WHERE pe.user_id = u.id + AND pe.started_at > now() - INTERVAL '7 days' + );