feat(users): timezone column + PUT /api/me/timezone
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user