package storage import ( "context" "errors" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" "github.com/alchemistkay/guestguard/internal/domain" ) type TokenRepo struct { pool *pgxpool.Pool } func NewTokenRepo(db *DB) *TokenRepo { return &TokenRepo{pool: db.Pool} } type CreateTokenParams struct { GuestID uuid.UUID TokenHash string ExpiresAt time.Time } func (r *TokenRepo) Create(ctx context.Context, p CreateTokenParams) (*domain.Token, error) { const q = ` INSERT INTO tokens (guest_id, token_hash, expires_at, status) VALUES ($1, $2, $3, 'active') RETURNING id, guest_id, token_hash, expires_at, status, used_at, created_at ` row := r.pool.QueryRow(ctx, q, p.GuestID, p.TokenHash, p.ExpiresAt) tk, err := scanToken(row) if err != nil { var pgErr *pgconn.PgError if errors.As(err, &pgErr) && pgErr.Code == "23505" { return nil, errors.New("guest already has a token") } return nil, err } return tk, nil } func (r *TokenRepo) GetByHash(ctx context.Context, hash string) (*domain.Token, error) { const q = ` SELECT id, guest_id, token_hash, expires_at, status, used_at, created_at FROM tokens WHERE token_hash = $1 ` tk, err := scanToken(r.pool.QueryRow(ctx, q, hash)) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, domain.ErrTokenNotFound } return nil, err } return tk, nil } // RotateForGuest replaces the guest's existing token with a freshly-minted // one in a single transaction. The old token row is hard-deleted (the // guests.id UNIQUE constraint requires it, and "the old link must stop // working" is the point). Cascade-deletes the old access_logs rows that // reference it via the token_id FK with ON DELETE SET NULL — those rows // stay, with token_id nulled. func (r *TokenRepo) RotateForGuest(ctx context.Context, p CreateTokenParams) (*domain.Token, error) { tx, err := r.pool.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) if _, err := tx.Exec(ctx, `DELETE FROM tokens WHERE guest_id = $1`, p.GuestID); err != nil { return nil, err } const q = ` INSERT INTO tokens (guest_id, token_hash, expires_at, status) VALUES ($1, $2, $3, 'active') RETURNING id, guest_id, token_hash, expires_at, status, used_at, created_at ` row := tx.QueryRow(ctx, q, p.GuestID, p.TokenHash, p.ExpiresAt) tk, err := scanToken(row) if err != nil { return nil, err } if err := tx.Commit(ctx); err != nil { return nil, err } return tk, nil } func (r *TokenRepo) MarkUsed(ctx context.Context, id uuid.UUID) error { tag, err := r.pool.Exec(ctx, ` UPDATE tokens SET status = 'used', used_at = now() WHERE id = $1 AND status = 'active' `, id) if err != nil { return err } if tag.RowsAffected() == 0 { return domain.ErrTokenNotFound } return nil } func scanToken(s rowScanner) (*domain.Token, error) { var tk domain.Token err := s.Scan( &tk.ID, &tk.GuestID, &tk.TokenHash, &tk.ExpiresAt, &tk.Status, &tk.UsedAt, &tk.CreatedAt, ) if err != nil { return nil, err } return &tk, nil }