package storage import ( "context" "errors" "strings" "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 UserRepo struct { pool *pgxpool.Pool } func NewUserRepo(db *DB) *UserRepo { return &UserRepo{pool: db.Pool} } const userColumns = `id, email, name, COALESCE(password_hash, '') AS password_hash, email_verified, email_verified_at, deleted_at, terms_accepted_at, privacy_policy_accepted_at, created_at, updated_at` type CreateUserParams struct { Email string Name string PasswordHash string AcceptTerms bool // when true, records terms + privacy acceptance now } func (r *UserRepo) Create(ctx context.Context, p CreateUserParams) (*domain.User, error) { const q = ` INSERT INTO users ( email, name, password_hash, terms_accepted_at, privacy_policy_accepted_at ) VALUES ( $1, $2, NULLIF($3, ''), CASE WHEN $4 THEN now() ELSE NULL END, CASE WHEN $4 THEN now() ELSE NULL END ) RETURNING ` + userColumns row := r.pool.QueryRow(ctx, q, normaliseEmail(p.Email), strings.TrimSpace(p.Name), p.PasswordHash, p.AcceptTerms, ) u, err := scanUser(row) if err != nil { var pgErr *pgconn.PgError if errors.As(err, &pgErr) && pgErr.Code == "23505" { return nil, domain.ErrEmailTaken } return nil, err } return u, nil } // GetByID returns an active (non-soft-deleted) user. Soft-deleted users // are treated as "not found" by the API surface — keeps the deletion // flow safe by default. func (r *UserRepo) GetByID(ctx context.Context, id uuid.UUID) (*domain.User, error) { const q = `SELECT ` + userColumns + ` FROM users WHERE id = $1 AND deleted_at IS NULL` u, err := scanUser(r.pool.QueryRow(ctx, q, id)) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, domain.ErrUserNotFound } return nil, err } return u, nil } // GetByEmail mirrors GetByID — soft-deleted users vanish from email // lookups (so signup/login don't match a tombstoned record). func (r *UserRepo) GetByEmail(ctx context.Context, email string) (*domain.User, error) { const q = `SELECT ` + userColumns + ` FROM users WHERE email = $1 AND deleted_at IS NULL` u, err := scanUser(r.pool.QueryRow(ctx, q, normaliseEmail(email))) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, domain.ErrUserNotFound } return nil, err } return u, nil } // SoftDelete marks the user as deleted and clears their PII-bearing // fields. A nightly cron (TBD in ops) will hard-delete rows older than // 30 days. Until then the row exists for audit + recovery if the user // changes their mind. func (r *UserRepo) SoftDelete(ctx context.Context, id uuid.UUID) error { tag, err := r.pool.Exec(ctx, ` UPDATE users SET deleted_at = now(), updated_at = now(), -- Tombstone PII so the soft-deleted row can sit for 30 days -- without holding the user's real email + name in cleartext. -- The original values are gone from the API surface from the -- moment SoftDelete returns. email = 'deleted-' || id::text || '@deleted.local', name = 'Deleted user', password_hash = NULL WHERE id = $1 AND deleted_at IS NULL `, id) if err != nil { return err } if tag.RowsAffected() == 0 { return domain.ErrUserNotFound } return nil } // AcceptTerms records that the user has consented to the current terms // of service and privacy policy. Idempotent — re-accepting just resets // the timestamp. func (r *UserRepo) AcceptTerms(ctx context.Context, id uuid.UUID) error { tag, err := r.pool.Exec(ctx, ` UPDATE users SET terms_accepted_at = now(), privacy_policy_accepted_at = now(), updated_at = now() WHERE id = $1 AND deleted_at IS NULL `, id) if err != nil { return err } if tag.RowsAffected() == 0 { return domain.ErrUserNotFound } return nil } func (r *UserRepo) MarkEmailVerified(ctx context.Context, id uuid.UUID) error { tag, err := r.pool.Exec(ctx, ` UPDATE users SET email_verified = TRUE, email_verified_at = COALESCE(email_verified_at, now()), updated_at = now() WHERE id = $1 `, id) if err != nil { return err } if tag.RowsAffected() == 0 { return domain.ErrUserNotFound } return nil } func (r *UserRepo) UpdatePasswordHash(ctx context.Context, id uuid.UUID, hash string) error { tag, err := r.pool.Exec(ctx, ` UPDATE users SET password_hash = $2, updated_at = now() WHERE id = $1 `, id, hash) if err != nil { return err } if tag.RowsAffected() == 0 { return domain.ErrUserNotFound } return nil } func scanUser(s rowScanner) (*domain.User, error) { var u domain.User if err := s.Scan( &u.ID, &u.Email, &u.Name, &u.PasswordHash, &u.EmailVerified, &u.EmailVerifiedAt, &u.DeletedAt, &u.TermsAcceptedAt, &u.PrivacyPolicyAcceptedAt, &u.CreatedAt, &u.UpdatedAt, ); err != nil { return nil, err } return &u, nil } func normaliseEmail(s string) string { return strings.ToLower(strings.TrimSpace(s)) }