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)
|
||||
}
|
||||
}
|
||||
@@ -522,6 +522,8 @@ type User struct {
|
||||
DisplayName *string
|
||||
AutoApproveRequests bool
|
||||
Email *string
|
||||
Timezone string
|
||||
TimezoneUpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type UserInvite struct {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
-- 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'
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user