// SPDX-License-Identifier: AGPL-3.0-or-later package store import ( "database/sql" "errors" "fmt" "strings" "time" "kode.naiv.no/olemd/favoritter/internal/model" ) var ( ErrSignupRequestExists = errors.New("signup request already exists") ErrSignupRequestNotFound = errors.New("signup request not found") ) type SignupRequestStore struct { db *sql.DB } func NewSignupRequestStore(db *sql.DB) *SignupRequestStore { return &SignupRequestStore{db: db} } // Create stores a pending signup request with a hashed password. func (s *SignupRequestStore) Create(username, password string) error { hash, err := hashPassword(password) if err != nil { return fmt.Errorf("hash password: %w", err) } _, err = s.db.Exec( `INSERT INTO signup_requests (username, password_hash) VALUES (?, ?)`, username, hash, ) if err != nil { if strings.Contains(err.Error(), "UNIQUE constraint failed") { return ErrSignupRequestExists } return fmt.Errorf("insert signup request: %w", err) } return nil } // GetByID returns a signup request by ID. func (s *SignupRequestStore) GetByID(id int64) (*model.SignupRequest, error) { var sr model.SignupRequest var createdAt string var reviewedAt sql.NullString var reviewedBy sql.NullInt64 err := s.db.QueryRow( `SELECT id, username, password_hash, status, created_at, reviewed_at, reviewed_by FROM signup_requests WHERE id = ?`, id, ).Scan(&sr.ID, &sr.Username, &sr.PasswordHash, &sr.Status, &createdAt, &reviewedAt, &reviewedBy) if errors.Is(err, sql.ErrNoRows) { return nil, ErrSignupRequestNotFound } if err != nil { return nil, err } sr.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) if reviewedAt.Valid { t, _ := time.Parse(time.RFC3339, reviewedAt.String) sr.ReviewedAt = &t } if reviewedBy.Valid { sr.ReviewedBy = reviewedBy.Int64 } return &sr, nil } // ListPending returns all pending signup requests, newest first. func (s *SignupRequestStore) ListPending() ([]*model.SignupRequest, error) { rows, err := s.db.Query( `SELECT id, username, password_hash, status, created_at, reviewed_at, reviewed_by FROM signup_requests WHERE status = 'pending' ORDER BY created_at DESC`, ) if err != nil { return nil, err } defer rows.Close() var requests []*model.SignupRequest for rows.Next() { var sr model.SignupRequest var createdAt string var reviewedAt sql.NullString var reviewedBy sql.NullInt64 if err := rows.Scan(&sr.ID, &sr.Username, &sr.PasswordHash, &sr.Status, &createdAt, &reviewedAt, &reviewedBy); err != nil { return nil, err } sr.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) requests = append(requests, &sr) } return requests, rows.Err() } // Approve marks a request as approved and creates the user account. // The new user will have must_reset_password=1. func (s *SignupRequestStore) Approve(id int64, adminID int64) error { tx, err := s.db.Begin() if err != nil { return fmt.Errorf("begin tx: %w", err) } defer tx.Rollback() // Fetch and verify the request is still pending, within the transaction. var username, passwordHash, status string err = tx.QueryRow( `SELECT username, password_hash, status FROM signup_requests WHERE id = ?`, id, ).Scan(&username, &passwordHash, &status) if err != nil { return ErrSignupRequestNotFound } if status != "pending" { return fmt.Errorf("request is not pending (status: %s)", status) } // Create the user with the already-hashed password. _, err = tx.Exec( `INSERT INTO users (username, password_hash, must_reset_password) VALUES (?, ?, 1)`, username, passwordHash, ) if err != nil { if strings.Contains(err.Error(), "UNIQUE constraint failed") { return ErrUserExists } return fmt.Errorf("create user from request: %w", err) } // Mark the request as approved. _, err = tx.Exec( `UPDATE signup_requests SET status = 'approved', reviewed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), reviewed_by = ? WHERE id = ?`, adminID, id, ) if err != nil { return fmt.Errorf("update request status: %w", err) } return tx.Commit() } // Reject marks a request as rejected. func (s *SignupRequestStore) Reject(id int64, adminID int64) error { result, err := s.db.Exec( `UPDATE signup_requests SET status = 'rejected', reviewed_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), reviewed_by = ? WHERE id = ? AND status = 'pending'`, adminID, id, ) if err != nil { return err } n, _ := result.RowsAffected() if n == 0 { return ErrSignupRequestNotFound } return nil } // PendingCount returns the number of pending signup requests. func (s *SignupRequestStore) PendingCount() (int, error) { var n int err := s.db.QueryRow("SELECT COUNT(*) FROM signup_requests WHERE status = 'pending'").Scan(&n) return n, err }