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.Get("/me/history", h.handleGetMyHistory)
|
||||||
authed.Put("/me/password", h.handleChangePassword)
|
authed.Put("/me/password", h.handleChangePassword)
|
||||||
authed.Put("/me/profile", h.handleUpdateMyProfile)
|
authed.Put("/me/profile", h.handleUpdateMyProfile)
|
||||||
|
authed.Put("/me/timezone", h.handlePutTimezone)
|
||||||
authed.Get("/me/api-token", h.handleGetMyAPIToken)
|
authed.Get("/me/api-token", h.handleGetMyAPIToken)
|
||||||
authed.Post("/me/api-token", h.handleRegenerateMyAPIToken)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -522,6 +522,8 @@ type User struct {
|
|||||||
DisplayName *string
|
DisplayName *string
|
||||||
AutoApproveRequests bool
|
AutoApproveRequests bool
|
||||||
Email *string
|
Email *string
|
||||||
|
Timezone string
|
||||||
|
TimezoneUpdatedAt pgtype.Timestamptz
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserInvite struct {
|
type UserInvite struct {
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func (q *Queries) CountUsers(ctx context.Context) (int64, error) {
|
|||||||
const createUser = `-- name: CreateUser :one
|
const createUser = `-- name: CreateUser :one
|
||||||
INSERT INTO users (username, password_hash, api_token, is_admin, display_name)
|
INSERT INTO users (username, password_hash, api_token, is_admin, display_name)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
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 {
|
type CreateUserParams struct {
|
||||||
@@ -87,6 +87,8 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
|
|||||||
&i.DisplayName,
|
&i.DisplayName,
|
||||||
&i.AutoApproveRequests,
|
&i.AutoApproveRequests,
|
||||||
&i.Email,
|
&i.Email,
|
||||||
|
&i.Timezone,
|
||||||
|
&i.TimezoneUpdatedAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -94,7 +96,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
|
|||||||
const createUserAdmin = `-- name: CreateUserAdmin :one
|
const createUserAdmin = `-- name: CreateUserAdmin :one
|
||||||
INSERT INTO users (username, password_hash, api_token, is_admin, display_name)
|
INSERT INTO users (username, password_hash, api_token, is_admin, display_name)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
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 {
|
type CreateUserAdminParams struct {
|
||||||
@@ -130,6 +132,8 @@ func (q *Queries) CreateUserAdmin(ctx context.Context, arg CreateUserAdminParams
|
|||||||
&i.DisplayName,
|
&i.DisplayName,
|
||||||
&i.AutoApproveRequests,
|
&i.AutoApproveRequests,
|
||||||
&i.Email,
|
&i.Email,
|
||||||
|
&i.Timezone,
|
||||||
|
&i.TimezoneUpdatedAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -141,7 +145,7 @@ VALUES (
|
|||||||
(SELECT NOT EXISTS (SELECT 1 FROM users)),
|
(SELECT NOT EXISTS (SELECT 1 FROM users)),
|
||||||
$4
|
$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 {
|
type CreateUserFirstAdminRaceParams struct {
|
||||||
@@ -185,6 +189,8 @@ func (q *Queries) CreateUserFirstAdminRace(ctx context.Context, arg CreateUserFi
|
|||||||
&i.DisplayName,
|
&i.DisplayName,
|
||||||
&i.AutoApproveRequests,
|
&i.AutoApproveRequests,
|
||||||
&i.Email,
|
&i.Email,
|
||||||
|
&i.Timezone,
|
||||||
|
&i.TimezoneUpdatedAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -231,7 +237,7 @@ func (q *Queries) GetListenBrainzConfig(ctx context.Context, id pgtype.UUID) (Ge
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getUserByAPIToken = `-- name: GetUserByAPIToken :one
|
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) {
|
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.DisplayName,
|
||||||
&i.AutoApproveRequests,
|
&i.AutoApproveRequests,
|
||||||
&i.Email,
|
&i.Email,
|
||||||
|
&i.Timezone,
|
||||||
|
&i.TimezoneUpdatedAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getUserByEmail = `-- name: GetUserByEmail :one
|
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
|
// 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.DisplayName,
|
||||||
&i.AutoApproveRequests,
|
&i.AutoApproveRequests,
|
||||||
&i.Email,
|
&i.Email,
|
||||||
|
&i.Timezone,
|
||||||
|
&i.TimezoneUpdatedAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getUserByID = `-- name: GetUserByID :one
|
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) {
|
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.DisplayName,
|
||||||
&i.AutoApproveRequests,
|
&i.AutoApproveRequests,
|
||||||
&i.Email,
|
&i.Email,
|
||||||
|
&i.Timezone,
|
||||||
|
&i.TimezoneUpdatedAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getUserByUsername = `-- name: GetUserByUsername :one
|
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) {
|
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.DisplayName,
|
||||||
&i.AutoApproveRequests,
|
&i.AutoApproveRequests,
|
||||||
&i.Email,
|
&i.Email,
|
||||||
|
&i.Timezone,
|
||||||
|
&i.TimezoneUpdatedAt,
|
||||||
)
|
)
|
||||||
return i, err
|
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
|
const listUsers = `-- name: ListUsers :many
|
||||||
SELECT id, username, display_name, is_admin, auto_approve_requests, created_at
|
SELECT id, username, display_name, is_admin, auto_approve_requests, created_at
|
||||||
FROM users
|
FROM users
|
||||||
@@ -374,7 +426,7 @@ func (q *Queries) ListUsers(ctx context.Context) ([]ListUsersRow, error) {
|
|||||||
|
|
||||||
const regenerateApiToken = `-- name: RegenerateApiToken :one
|
const regenerateApiToken = `-- name: RegenerateApiToken :one
|
||||||
UPDATE users SET api_token = $2 WHERE id = $1
|
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 {
|
type RegenerateApiTokenParams struct {
|
||||||
@@ -400,6 +452,8 @@ func (q *Queries) RegenerateApiToken(ctx context.Context, arg RegenerateApiToken
|
|||||||
&i.DisplayName,
|
&i.DisplayName,
|
||||||
&i.AutoApproveRequests,
|
&i.AutoApproveRequests,
|
||||||
&i.Email,
|
&i.Email,
|
||||||
|
&i.Timezone,
|
||||||
|
&i.TimezoneUpdatedAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -477,7 +531,7 @@ const updateUserAdmin = `-- name: UpdateUserAdmin :one
|
|||||||
UPDATE users
|
UPDATE users
|
||||||
SET is_admin = $2
|
SET is_admin = $2
|
||||||
WHERE id = $1
|
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 {
|
type UpdateUserAdminParams struct {
|
||||||
@@ -503,6 +557,8 @@ func (q *Queries) UpdateUserAdmin(ctx context.Context, arg UpdateUserAdminParams
|
|||||||
&i.DisplayName,
|
&i.DisplayName,
|
||||||
&i.AutoApproveRequests,
|
&i.AutoApproveRequests,
|
||||||
&i.Email,
|
&i.Email,
|
||||||
|
&i.Timezone,
|
||||||
|
&i.TimezoneUpdatedAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -511,7 +567,7 @@ const updateUserAutoApprove = `-- name: UpdateUserAutoApprove :one
|
|||||||
UPDATE users
|
UPDATE users
|
||||||
SET auto_approve_requests = $2
|
SET auto_approve_requests = $2
|
||||||
WHERE id = $1
|
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 {
|
type UpdateUserAutoApproveParams struct {
|
||||||
@@ -536,6 +592,8 @@ func (q *Queries) UpdateUserAutoApprove(ctx context.Context, arg UpdateUserAutoA
|
|||||||
&i.DisplayName,
|
&i.DisplayName,
|
||||||
&i.AutoApproveRequests,
|
&i.AutoApproveRequests,
|
||||||
&i.Email,
|
&i.Email,
|
||||||
|
&i.Timezone,
|
||||||
|
&i.TimezoneUpdatedAt,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -545,7 +603,7 @@ UPDATE users
|
|||||||
SET display_name = $2,
|
SET display_name = $2,
|
||||||
email = $3
|
email = $3
|
||||||
WHERE id = $1
|
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 {
|
type UpdateUserProfileParams struct {
|
||||||
@@ -572,6 +630,28 @@ func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfilePa
|
|||||||
&i.DisplayName,
|
&i.DisplayName,
|
||||||
&i.AutoApproveRequests,
|
&i.AutoApproveRequests,
|
||||||
&i.Email,
|
&i.Email,
|
||||||
|
&i.Timezone,
|
||||||
|
&i.TimezoneUpdatedAt,
|
||||||
)
|
)
|
||||||
return i, err
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE users
|
||||||
|
DROP COLUMN timezone,
|
||||||
|
DROP COLUMN timezone_updated_at;
|
||||||
@@ -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;
|
||||||
@@ -142,3 +142,24 @@ RETURNING *;
|
|||||||
-- to keep the unique index happy and to make the lookup
|
-- to keep the unique index happy and to make the lookup
|
||||||
-- case-insensitive.
|
-- case-insensitive.
|
||||||
SELECT * FROM users WHERE lower(email) = lower($1);
|
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'
|
||||||
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user