650 lines
22 KiB
Go
650 lines
22 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/prabhupadasays/spsays-bff/internal/model"
|
|
)
|
|
|
|
// ErrUserNotFound is returned by lookup methods when no row matches.
|
|
var ErrUserNotFound = errors.New("user not found")
|
|
|
|
// ErrEmailTaken is returned by Create when the email already exists.
|
|
var ErrEmailTaken = errors.New("email already registered")
|
|
|
|
// ErrHandleTaken is returned when the chosen handle collides with an existing user.
|
|
var ErrHandleTaken = errors.New("handle already in use")
|
|
|
|
// UserRepo provides CRUD on the users table.
|
|
type UserRepo struct {
|
|
pg *Postgres
|
|
}
|
|
|
|
func NewUserRepo(pg *Postgres) *UserRepo {
|
|
return &UserRepo{pg: pg}
|
|
}
|
|
|
|
// userColumns is the canonical column list for SELECT/RETURNING on the
|
|
// users table, in the exact order scanUser reads them. Keeping the two in
|
|
// lockstep here is what makes adding a column a single-edit change instead
|
|
// of a hunt across every query.
|
|
const userColumns = `id, email, handle, password_hash, name, avatar_url, role, plan,
|
|
email_verified_at, token_version, created_at, updated_at, country,
|
|
sampradaya, gender, initiated, initiated_at, daily_chanting_rounds,
|
|
preferred_chanting_time, peak_active_time, banned_at,
|
|
bookmark_limit, public_collection_limit, private_collection_limit,
|
|
disciple_id, bio`
|
|
|
|
// rowScanner is satisfied by both pgx.Row (QueryRow) and pgx.Rows, so
|
|
// scanUser works for single-row and iterated reads alike.
|
|
type rowScanner interface {
|
|
Scan(dest ...any) error
|
|
}
|
|
|
|
// scanUser reads one users row (selected via userColumns) into u.
|
|
func scanUser(row rowScanner, u *model.User) error {
|
|
return row.Scan(
|
|
&u.ID, &u.Email, &u.Handle, &u.PasswordHash, &u.Name, &u.AvatarURL, &u.Role, &u.Plan,
|
|
&u.EmailVerifiedAt, &u.TokenVersion, &u.CreatedAt, &u.UpdatedAt, &u.Country,
|
|
&u.Sampradaya, &u.Gender, &u.Initiated, &u.InitiatedAt, &u.DailyChantingRounds,
|
|
&u.PreferredChantingTime, &u.PeakActiveTime, &u.BannedAt,
|
|
&u.BookmarkLimit, &u.PublicCollectionLimit, &u.PrivateCollectionLimit,
|
|
&u.DiscipleID, &u.Bio,
|
|
)
|
|
}
|
|
|
|
// ProfileUpdate is the set of fields UpdateProfile can change. Every field
|
|
// is a pointer so "leave unchanged" (nil) is distinct from "set". For the
|
|
// nullable string fields, a non-nil pointer to "" means "clear to NULL";
|
|
// DailyChantingRounds uses *0 as its clear sentinel (1 is the real min).
|
|
type ProfileUpdate struct {
|
|
Name *string
|
|
AvatarURL *string
|
|
Handle *string
|
|
Bio *string // nil=leave, ""=clear, text=set
|
|
Country *string
|
|
Sampradaya *string // nil=leave, ""=clear, code=set
|
|
Gender *string
|
|
Initiated *bool
|
|
InitiatedAt *string // "YYYY-MM-DD" to set, "" to clear
|
|
DailyChantingRounds *int // 0 to clear
|
|
PreferredChantingTime *string
|
|
PeakActiveTime *string
|
|
}
|
|
|
|
// Create inserts a new user. Returns ErrEmailTaken or ErrHandleTaken on
|
|
// unique-violation. The caller is responsible for picking a unique handle —
|
|
// the auth service does this with a retry loop.
|
|
func (r *UserRepo) Create(ctx context.Context, email, passwordHash, name, handle string) (*model.User, error) {
|
|
q := `
|
|
INSERT INTO users (email, password_hash, name, handle)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING ` + userColumns
|
|
|
|
u := &model.User{}
|
|
err := scanUser(r.pg.Pool.QueryRow(ctx, q,
|
|
strings.ToLower(strings.TrimSpace(email)), passwordHash, name, handle,
|
|
), u)
|
|
if err != nil {
|
|
if isUniqueViolation(err, "users_handle_key") {
|
|
return nil, ErrHandleTaken
|
|
}
|
|
if isUniqueViolation(err, "users_email_key") {
|
|
return nil, ErrEmailTaken
|
|
}
|
|
return nil, fmt.Errorf("create user: %w", err)
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
// FindByEmail returns the user with the given email, or ErrUserNotFound.
|
|
func (r *UserRepo) FindByEmail(ctx context.Context, email string) (*model.User, error) {
|
|
return r.findOne(ctx,
|
|
`WHERE email = $1`, strings.ToLower(strings.TrimSpace(email)))
|
|
}
|
|
|
|
// CreateOAuth inserts a user with a NULL password_hash — used by the OAuth
|
|
// sign-in flow when there's no matching email yet. Email is nullable here:
|
|
// providers that don't return one (e.g. phone-only Facebook accounts) end
|
|
// up as users with email=NULL until they fill it in via profile settings.
|
|
// Same handle-collision retry contract as Create.
|
|
func (r *UserRepo) CreateOAuth(ctx context.Context, email *string, name, handle string, avatarURL *string, emailVerified bool) (*model.User, error) {
|
|
q := `
|
|
INSERT INTO users (email, password_hash, name, handle, avatar_url, email_verified_at)
|
|
VALUES ($1, NULL, $2, $3, $4, CASE WHEN $5::boolean THEN now() ELSE NULL END)
|
|
RETURNING ` + userColumns
|
|
var normEmail *string
|
|
if email != nil {
|
|
v := strings.ToLower(strings.TrimSpace(*email))
|
|
if v != "" {
|
|
normEmail = &v
|
|
}
|
|
}
|
|
u := &model.User{}
|
|
err := scanUser(r.pg.Pool.QueryRow(ctx, q,
|
|
normEmail, name, handle, avatarURL, emailVerified,
|
|
), u)
|
|
if err != nil {
|
|
if isUniqueViolation(err, "users_handle_key") {
|
|
return nil, ErrHandleTaken
|
|
}
|
|
if isUniqueViolation(err, "users_email_key") {
|
|
return nil, ErrEmailTaken
|
|
}
|
|
return nil, fmt.Errorf("create oauth user: %w", err)
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
// FindByID returns the user with the given id, or ErrUserNotFound.
|
|
func (r *UserRepo) FindByID(ctx context.Context, id string) (*model.User, error) {
|
|
return r.findOne(ctx, `WHERE id = $1`, id)
|
|
}
|
|
|
|
// FindByHandle returns the user with the given handle (case-insensitive
|
|
// thanks to the citext column), or ErrUserNotFound.
|
|
func (r *UserRepo) FindByHandle(ctx context.Context, handle string) (*model.User, error) {
|
|
return r.findOne(ctx, `WHERE handle = $1`, strings.TrimSpace(handle))
|
|
}
|
|
|
|
func (r *UserRepo) findOne(ctx context.Context, where string, args ...any) (*model.User, error) {
|
|
q := `SELECT ` + userColumns + ` FROM users ` + where + ` LIMIT 1`
|
|
|
|
u := &model.User{}
|
|
err := scanUser(r.pg.Pool.QueryRow(ctx, q, args...), u)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrUserNotFound
|
|
}
|
|
return nil, fmt.Errorf("find user: %w", err)
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
// SetEmail fills in (or replaces) a user's email and resets email_verified_at
|
|
// to NULL so a fresh verification flow can run. Returns ErrEmailTaken if
|
|
// another row already owns this address.
|
|
//
|
|
// Used by the "add your email later" flow for OAuth users who signed up
|
|
// without one. The caller (auth service) is expected to follow up by
|
|
// minting and sending a verification token.
|
|
func (r *UserRepo) SetEmail(ctx context.Context, id, email string) (*model.User, error) {
|
|
q := `
|
|
UPDATE users
|
|
SET email = $2,
|
|
email_verified_at = NULL,
|
|
updated_at = now()
|
|
WHERE id = $1
|
|
RETURNING ` + userColumns
|
|
|
|
u := &model.User{}
|
|
err := scanUser(r.pg.Pool.QueryRow(ctx, q, id, strings.ToLower(strings.TrimSpace(email))), u)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrUserNotFound
|
|
}
|
|
if isUniqueViolation(err, "users_email_key") {
|
|
return nil, ErrEmailTaken
|
|
}
|
|
return nil, fmt.Errorf("set email: %w", err)
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
// UpdateProfile applies a partial profile patch. Only the non-nil fields of
|
|
// `p` are touched; the SQL is built dynamically so an untouched column is
|
|
// never written (keeps updated_at honest and the patch minimal). For the
|
|
// nullable string fields a non-nil "" clears the column to NULL.
|
|
//
|
|
// `handle` collisions surface as ErrHandleTaken so the handler can render
|
|
// a useful message.
|
|
func (r *UserRepo) UpdateProfile(ctx context.Context, id string, p ProfileUpdate) (*model.User, error) {
|
|
sets := make([]string, 0, 10)
|
|
args := []any{id}
|
|
|
|
// set appends "col = $N" with a bound value.
|
|
set := func(col string, val any) {
|
|
args = append(args, val)
|
|
sets = append(sets, fmt.Sprintf("%s = $%d", col, len(args)))
|
|
}
|
|
// setExpr appends a parameterless SET clause (e.g. "col = NULL").
|
|
setExpr := func(expr string) { sets = append(sets, expr) }
|
|
// nullableString handles the nil=leave / ""=clear / value=set trio.
|
|
nullableString := func(col string, v *string) {
|
|
if v == nil {
|
|
return
|
|
}
|
|
if *v == "" {
|
|
setExpr(col + " = NULL")
|
|
} else {
|
|
set(col, *v)
|
|
}
|
|
}
|
|
|
|
if p.Name != nil {
|
|
set("name", *p.Name)
|
|
}
|
|
if p.Handle != nil {
|
|
set("handle", *p.Handle)
|
|
}
|
|
nullableString("avatar_url", p.AvatarURL)
|
|
nullableString("bio", p.Bio)
|
|
nullableString("country", p.Country)
|
|
nullableString("sampradaya", p.Sampradaya)
|
|
nullableString("gender", p.Gender)
|
|
nullableString("preferred_chanting_time", p.PreferredChantingTime)
|
|
nullableString("peak_active_time", p.PeakActiveTime)
|
|
if p.Initiated != nil {
|
|
set("initiated", *p.Initiated)
|
|
}
|
|
if p.InitiatedAt != nil {
|
|
if *p.InitiatedAt == "" {
|
|
setExpr("initiated_at = NULL")
|
|
} else {
|
|
args = append(args, *p.InitiatedAt)
|
|
sets = append(sets, fmt.Sprintf("initiated_at = $%d::date", len(args)))
|
|
}
|
|
}
|
|
if p.DailyChantingRounds != nil {
|
|
if *p.DailyChantingRounds == 0 {
|
|
setExpr("daily_chanting_rounds = NULL")
|
|
} else {
|
|
set("daily_chanting_rounds", *p.DailyChantingRounds)
|
|
}
|
|
}
|
|
|
|
// Nothing to change — return the current row rather than a no-op UPDATE.
|
|
if len(sets) == 0 {
|
|
return r.findOne(ctx, `WHERE id = $1`, id)
|
|
}
|
|
sets = append(sets, "updated_at = now()")
|
|
|
|
q := `UPDATE users SET ` + strings.Join(sets, ", ") + ` WHERE id = $1 RETURNING ` + userColumns
|
|
|
|
u := &model.User{}
|
|
err := scanUser(r.pg.Pool.QueryRow(ctx, q, args...), u)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrUserNotFound
|
|
}
|
|
if isUniqueViolation(err, "users_handle_key") {
|
|
return nil, ErrHandleTaken
|
|
}
|
|
return nil, fmt.Errorf("update profile: %w", err)
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
// SetCountryIfEmpty fills in the account country only when it's still
|
|
// NULL — used by the best-effort signup capture, so a later manual edit
|
|
// (or a second capture) never clobbers a value the user chose. `cc` is an
|
|
// ISO-3166-1 alpha-2 code; callers pass "" to no-op. Errors are the
|
|
// caller's to swallow (capture is fire-and-forget).
|
|
func (r *UserRepo) SetCountryIfEmpty(ctx context.Context, id, cc string) error {
|
|
cc = strings.ToUpper(strings.TrimSpace(cc))
|
|
if len(cc) != 2 {
|
|
return nil
|
|
}
|
|
_, err := r.pg.Pool.Exec(ctx, `
|
|
UPDATE users SET country = $2, updated_at = now()
|
|
WHERE id = $1 AND country IS NULL`, id, cc)
|
|
if err != nil {
|
|
return fmt.Errorf("set country: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// UpdatePassword sets a new password hash and bumps token_version so any
|
|
// existing JWTs immediately become invalid.
|
|
func (r *UserRepo) UpdatePassword(ctx context.Context, id, newHash string) error {
|
|
const q = `
|
|
UPDATE users
|
|
SET password_hash = $2,
|
|
token_version = token_version + 1,
|
|
updated_at = now()
|
|
WHERE id = $1`
|
|
tag, err := r.pg.Pool.Exec(ctx, q, id, newHash)
|
|
if err != nil {
|
|
return fmt.Errorf("update password: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrUserNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Search returns users whose name or handle matches `q` (ILIKE
|
|
// substring). Leading "@" is stripped — callers can pass either
|
|
// "@foo" or "foo". Ordering bubbles handle-prefix matches above
|
|
// name-prefix above the rest, then alphabetic by name as the
|
|
// tiebreaker.
|
|
//
|
|
// Postgres ILIKE is fine at our user-table scale (low six figures
|
|
// max). When the table grows enough to warrant fuzzy / trigram
|
|
// matching, add a pg_trgm GIN index and switch to similarity().
|
|
func (r *UserRepo) Search(ctx context.Context, q string, limit, offset int) ([]model.User, int, error) {
|
|
cleaned := strings.TrimPrefix(strings.TrimSpace(q), "@")
|
|
if cleaned == "" {
|
|
return []model.User{}, 0, nil
|
|
}
|
|
if limit <= 0 || limit > 50 {
|
|
limit = 20
|
|
}
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
substring := "%" + cleaned + "%"
|
|
prefix := cleaned + "%"
|
|
|
|
// Total matches for pagination — same WHERE as the page query. Cheap
|
|
// enough at our user-table scale; revisit alongside the pg_trgm switch.
|
|
var total int
|
|
if err := r.pg.Pool.QueryRow(ctx,
|
|
`SELECT COUNT(*) FROM users
|
|
WHERE (name ILIKE $1 OR handle::text ILIKE $1) AND banned_at IS NULL`,
|
|
substring).Scan(&total); err != nil {
|
|
return nil, 0, fmt.Errorf("count search users: %w", err)
|
|
}
|
|
if total == 0 || offset >= total {
|
|
return []model.User{}, total, nil
|
|
}
|
|
|
|
// The trailing `handle` tiebreaker makes the ordering total (handle is
|
|
// unique) so OFFSET paging is stable — without it, rows sharing a rank
|
|
// and name could reshuffle between pages.
|
|
sql := `SELECT ` + userColumns + `
|
|
FROM users
|
|
WHERE (name ILIKE $1 OR handle::text ILIKE $1)
|
|
-- Suspended accounts are unsearchable. This one query backs the
|
|
-- people search page, the mention autocomplete AND both chat member
|
|
-- pickers, so a banned account appearing here is offered as someone
|
|
-- to @mention or add to a group. ResolveHandles already refuses them
|
|
-- on the way in, which without this makes "Add" silently do nothing.
|
|
AND banned_at IS NULL
|
|
ORDER BY
|
|
CASE
|
|
WHEN handle::text ILIKE $2 THEN 0
|
|
WHEN name ILIKE $2 THEN 1
|
|
ELSE 2
|
|
END,
|
|
name,
|
|
handle
|
|
LIMIT $3 OFFSET $4`
|
|
|
|
rows, err := r.pg.Pool.Query(ctx, sql, substring, prefix, limit, offset)
|
|
if err != nil {
|
|
return nil, 0, fmt.Errorf("search users: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
out := []model.User{}
|
|
for rows.Next() {
|
|
var u model.User
|
|
if err := scanUser(rows, &u); err != nil {
|
|
return nil, 0, fmt.Errorf("scan user: %w", err)
|
|
}
|
|
out = append(out, u)
|
|
}
|
|
return out, total, rows.Err()
|
|
}
|
|
|
|
// AllHandles returns every user's handle, for the public-profile sitemap.
|
|
// Projection-only (handle is unique + indexed), cheap to sweep. Excludes the
|
|
// reserved "me" handle (the signed-in user's own private library route).
|
|
func (r *UserRepo) AllHandles(ctx context.Context) ([]string, error) {
|
|
rows, err := r.pg.Pool.Query(ctx,
|
|
`SELECT handle FROM users WHERE handle <> '' AND handle::text <> 'me' ORDER BY handle`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list handles: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
handles := []string{}
|
|
for rows.Next() {
|
|
var h string
|
|
if err := rows.Scan(&h); err != nil {
|
|
return nil, fmt.Errorf("scan handle: %w", err)
|
|
}
|
|
handles = append(handles, h)
|
|
}
|
|
return handles, rows.Err()
|
|
}
|
|
|
|
// DiscipleLink pairs a Mongo disciple id with the platform account an admin
|
|
// has linked to it. Powers the /disciples browser highlight: the reader page
|
|
// fetches the whole (small) set once and marks the matching cards.
|
|
type DiscipleLink struct {
|
|
DiscipleID string `json:"disciple_id"`
|
|
Handle string `json:"handle"`
|
|
Name string `json:"name"`
|
|
AvatarURL *string `json:"avatar_url"`
|
|
}
|
|
|
|
// ListDiscipleLinks returns every user→disciple link. Deliberately unpaged:
|
|
// only Śrīla Prabhupāda's own disciples are ever linked, so this is dozens of
|
|
// rows at most and the partial index on disciple_id keeps it a cheap scan.
|
|
// Suspended accounts are excluded so a banned user can't keep a public badge.
|
|
func (r *UserRepo) ListDiscipleLinks(ctx context.Context) ([]DiscipleLink, error) {
|
|
rows, err := r.pg.Pool.Query(ctx, `
|
|
SELECT disciple_id, handle, name, avatar_url
|
|
FROM users
|
|
WHERE disciple_id IS NOT NULL AND banned_at IS NULL
|
|
ORDER BY name`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list disciple links: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
out := []DiscipleLink{}
|
|
for rows.Next() {
|
|
var l DiscipleLink
|
|
if err := rows.Scan(&l.DiscipleID, &l.Handle, &l.Name, &l.AvatarURL); err != nil {
|
|
return nil, fmt.Errorf("scan disciple link: %w", err)
|
|
}
|
|
out = append(out, l)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// RecentlyJoined returns up to `limit` users who registered on/after `since`,
|
|
// newest first, as a discovery list for the feed's "new on the platform"
|
|
// strip. Excludes the viewer, anyone the viewer already has a follow edge with
|
|
// (pending or accepted), and anyone involved in a block either direction — so
|
|
// every suggestion is a fresh account the viewer can actually follow.
|
|
func (r *UserRepo) RecentlyJoined(ctx context.Context, viewerID string, since time.Time, limit int) ([]model.UserSummary, error) {
|
|
if limit <= 0 || limit > 20 {
|
|
limit = 5
|
|
}
|
|
const q = `
|
|
SELECT u.id, u.handle, u.name, u.avatar_url
|
|
FROM users u
|
|
WHERE u.created_at >= $2
|
|
AND u.id <> $1
|
|
-- Suspended accounts are not discoverable. A ban hides someone from
|
|
-- the app; surfacing them in the welcome strip would be the most
|
|
-- prominent placement on the home page.
|
|
AND u.banned_at IS NULL
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM follows f
|
|
WHERE f.follower_id = $1 AND f.following_id = u.id
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM user_blocks b
|
|
WHERE (b.blocker_id = $1 AND b.blocked_id = u.id)
|
|
OR (b.blocker_id = u.id AND b.blocked_id = $1)
|
|
)
|
|
ORDER BY u.created_at DESC
|
|
LIMIT $3`
|
|
rows, err := r.pg.Pool.Query(ctx, q, viewerID, since, limit)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("recently joined: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
out := []model.UserSummary{}
|
|
for rows.Next() {
|
|
var u model.UserSummary
|
|
if err := rows.Scan(&u.ID, &u.Handle, &u.Name, &u.AvatarURL); err != nil {
|
|
return nil, fmt.Errorf("scan recently joined: %w", err)
|
|
}
|
|
out = append(out, u)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// GetReadingPrefs returns the raw JSONB bytes for the user's reading
|
|
// prefs (empty object when never set). The frontend owns the shape, so
|
|
// we round-trip the payload verbatim — no server-side schema enforced.
|
|
func (r *UserRepo) GetReadingPrefs(ctx context.Context, id string) ([]byte, error) {
|
|
const q = `SELECT reading_prefs FROM users WHERE id = $1 LIMIT 1`
|
|
var raw []byte
|
|
err := r.pg.Pool.QueryRow(ctx, q, id).Scan(&raw)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrUserNotFound
|
|
}
|
|
return nil, fmt.Errorf("get reading prefs: %w", err)
|
|
}
|
|
if len(raw) == 0 {
|
|
raw = []byte("{}")
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
// UpdateReadingPrefs overwrites the user's reading prefs blob. Caller is
|
|
// responsible for validating that `prefsJSON` is well-formed JSON (the
|
|
// handler does this by re-marshalling the decoded struct before calling
|
|
// in).
|
|
func (r *UserRepo) UpdateReadingPrefs(ctx context.Context, id string, prefsJSON []byte) error {
|
|
const q = `UPDATE users SET reading_prefs = $2::jsonb, updated_at = now() WHERE id = $1`
|
|
tag, err := r.pg.Pool.Exec(ctx, q, id, prefsJSON)
|
|
if err != nil {
|
|
return fmt.Errorf("update reading prefs: %w", err)
|
|
}
|
|
if tag.RowsAffected() == 0 {
|
|
return ErrUserNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MarkEmailVerified sets email_verified_at = now() if not already set.
|
|
func (r *UserRepo) MarkEmailVerified(ctx context.Context, id string) error {
|
|
const q = `UPDATE users SET email_verified_at = now(), updated_at = now()
|
|
WHERE id = $1 AND email_verified_at IS NULL`
|
|
_, err := r.pg.Pool.Exec(ctx, q, id)
|
|
if err != nil {
|
|
return fmt.Errorf("mark verified: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Helper for unique-violation detection. Avoids importing pgconn just for
|
|
// the SQLSTATE; we string-match on the message which contains the constraint
|
|
// name. Good enough for the one constraint we care about.
|
|
func isUniqueViolation(err error, constraint string) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
msg := err.Error()
|
|
return strings.Contains(msg, "23505") || strings.Contains(msg, constraint)
|
|
}
|
|
|
|
// AuthTokenRepo handles password_reset + email_verification tokens.
|
|
type AuthTokenRepo struct {
|
|
pg *Postgres
|
|
}
|
|
|
|
func NewAuthTokenRepo(pg *Postgres) *AuthTokenRepo {
|
|
return &AuthTokenRepo{pg: pg}
|
|
}
|
|
|
|
// Create inserts a new token row. `tokenHash` must be the SHA-256 hex of the
|
|
// raw token; only the hash is stored, never the plaintext.
|
|
func (r *AuthTokenRepo) Create(ctx context.Context, userID, purpose, tokenHash string, ttl time.Duration) (*model.AuthToken, error) {
|
|
const q = `
|
|
INSERT INTO auth_tokens (user_id, purpose, token_hash, expires_at)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING id, user_id, purpose, token_hash, expires_at, used_at, created_at`
|
|
|
|
t := &model.AuthToken{}
|
|
err := r.pg.Pool.QueryRow(ctx, q, userID, purpose, tokenHash, time.Now().Add(ttl)).
|
|
Scan(&t.ID, &t.UserID, &t.Purpose, &t.TokenHash, &t.ExpiresAt, &t.UsedAt, &t.CreatedAt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create auth_token: %w", err)
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
// FindValid returns a non-used, non-expired token by hash + purpose.
|
|
func (r *AuthTokenRepo) FindValid(ctx context.Context, tokenHash, purpose string) (*model.AuthToken, error) {
|
|
const q = `
|
|
SELECT id, user_id, purpose, token_hash, expires_at, used_at, created_at
|
|
FROM auth_tokens
|
|
WHERE token_hash = $1
|
|
AND purpose = $2
|
|
AND used_at IS NULL
|
|
AND expires_at > now()
|
|
LIMIT 1`
|
|
|
|
t := &model.AuthToken{}
|
|
err := r.pg.Pool.QueryRow(ctx, q, tokenHash, purpose).
|
|
Scan(&t.ID, &t.UserID, &t.Purpose, &t.TokenHash, &t.ExpiresAt, &t.UsedAt, &t.CreatedAt)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrUserNotFound
|
|
}
|
|
return nil, fmt.Errorf("find token: %w", err)
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
// FindValidForUser returns a non-used, non-expired token by user + hash +
|
|
// purpose. Used by the OTP login flow where the keyspace is small (6
|
|
// digits) — scoping by user_id prevents a global hash-collision lookup.
|
|
func (r *AuthTokenRepo) FindValidForUser(ctx context.Context, userID, tokenHash, purpose string) (*model.AuthToken, error) {
|
|
const q = `
|
|
SELECT id, user_id, purpose, token_hash, expires_at, used_at, created_at
|
|
FROM auth_tokens
|
|
WHERE user_id = $1
|
|
AND token_hash = $2
|
|
AND purpose = $3
|
|
AND used_at IS NULL
|
|
AND expires_at > now()
|
|
LIMIT 1`
|
|
|
|
t := &model.AuthToken{}
|
|
err := r.pg.Pool.QueryRow(ctx, q, userID, tokenHash, purpose).
|
|
Scan(&t.ID, &t.UserID, &t.Purpose, &t.TokenHash, &t.ExpiresAt, &t.UsedAt, &t.CreatedAt)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, ErrUserNotFound
|
|
}
|
|
return nil, fmt.Errorf("find token: %w", err)
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
// Consume marks a token as used. After this call the token can never be
|
|
// honoured again, even if it has not yet expired.
|
|
func (r *AuthTokenRepo) Consume(ctx context.Context, id string) error {
|
|
const q = `UPDATE auth_tokens SET used_at = now() WHERE id = $1 AND used_at IS NULL`
|
|
_, err := r.pg.Pool.Exec(ctx, q, id)
|
|
if err != nil {
|
|
return fmt.Errorf("consume token: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// InvalidatePending marks all not-yet-used tokens for a user+purpose as used.
|
|
// Called when a fresh token is issued so old links are immediately dead.
|
|
func (r *AuthTokenRepo) InvalidatePending(ctx context.Context, userID, purpose string) error {
|
|
const q = `UPDATE auth_tokens SET used_at = now()
|
|
WHERE user_id = $1 AND purpose = $2 AND used_at IS NULL`
|
|
_, err := r.pg.Pool.Exec(ctx, q, userID, purpose)
|
|
if err != nil {
|
|
return fmt.Errorf("invalidate pending tokens: %w", err)
|
|
}
|
|
return nil
|
|
}
|