package users import ( "context" "database/sql" "errors" "fmt" "strings" "time" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/audit" "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/store" ) // Store is the SQLite-backed Users service. type Store struct { db *sql.DB audit audit.Audit now func() time.Time } // New returns a Users service over db. aud may be nil (tests) — audit is then // skipped rather than panicking. func New(db *sql.DB, aud audit.Audit) *Store { return &Store{db: db, audit: aud, now: time.Now} } var _ Users = (*Store)(nil) // Create implements Users.Create (SPEC-004 §5, AC-6, D-019). func (s *Store) Create(ctx context.Context, creatorID int64, email, displayName string, role Role) (UserID, error) { email = strings.ToLower(strings.TrimSpace(email)) displayName = strings.TrimSpace(displayName) if email == "" { return 0, ErrEmailRequired } if displayName == "" { return 0, ErrNameRequired } if !role.Valid() { return 0, ErrInvalidRole } var manager Optional[UserID] switch { case creatorID == 0: // Bootstrap (D-021/D-022): the first user IS the top admin. n, err := s.Count(ctx) if err != nil { return 0, err } if n != 0 { return 0, ErrBootstrapClosed } if role != RoleAdmin { return 0, ErrBootstrapAdmin } manager = None[UserID]() default: creator, err := s.Get(ctx, creatorID) if err != nil { return 0, err } // One admin only — user 1 already exists (AC-6). if role == RoleAdmin { return 0, ErrOneAdmin } // Creation authority: a role strictly above the new user's (D-019). if !creator.Role.Above(role) { return 0, fmt.Errorf("%w: %s cannot create %s", ErrRoleNotAllowed, creator.Role, role) } manager = Some(creatorID) } createdAt := store.FormatTime(s.now()) res, err := s.db.Exec( `INSERT INTO users (email, display_name, role, manager_user_id, status, created_at) VALUES (?, ?, ?, ?, ?, ?)`, email, displayName, string(role), nullableID(manager), StatusActive, createdAt, ) if err != nil { if isUniqueViolation(err) { return 0, fmt.Errorf("%w: %s", ErrDuplicateEmail, email) } return 0, fmt.Errorf("users: insert: %w", err) } id, err := res.LastInsertId() if err != nil { return 0, fmt.Errorf("users: last insert id: %w", err) } s.record(ctx, audit.Event{ ActorUserID: creatorID, Action: "user.create", Subject: fmt.Sprintf("user:%d", id), Detail: fmt.Sprintf("email=%s role=%s", email, role), }) return id, nil } // Get implements Users.Get. func (s *Store) Get(ctx context.Context, id int64) (User, error) { row := s.db.QueryRow(` SELECT user_id, email, display_name, role, manager_user_id, passkey_opt_out, status, created_at FROM users WHERE user_id = ?`, id) return scanUser(row) } // SetRole implements Users.SetRole. The role ripples down (D-034); the // one-admin invariant is preserved (AC-6) and user 1 is protected (A14). func (s *Store) SetRole(ctx context.Context, id int64, role Role) error { if !role.Valid() { return ErrInvalidRole } switch { case id == 1 && role != RoleAdmin: return ErrTopAdmin case id != 1 && role == RoleAdmin: return ErrOneAdmin } if _, err := s.Get(ctx, id); err != nil { return err } if _, err := s.db.Exec(`UPDATE users SET role = ? WHERE user_id = ?`, string(role), id); err != nil { return fmt.Errorf("users: update role: %w", err) } // The actor of a role change is whoever the door authenticated, not the // user being changed — it comes from the audit.Meta on ctx (R-1/R-3). s.record(ctx, audit.Event{ Action: "user.set_role", Subject: fmt.Sprintf("user:%d", id), Detail: fmt.Sprintf("role=%s", role), }) return nil } // Manager implements Users.Manager — the chain link above id. func (s *Store) Manager(ctx context.Context, id int64) (Optional[UserID], error) { var m sql.NullInt64 err := s.db.QueryRow(`SELECT manager_user_id FROM users WHERE user_id = ?`, id).Scan(&m) if errors.Is(err, sql.ErrNoRows) { return None[UserID](), ErrNotFound } if err != nil { return None[UserID](), fmt.Errorf("users: manager lookup: %w", err) } if m.Valid { return Some(m.Int64), nil } return None[UserID](), nil } // Count returns the number of users. It answers "is the instance in setup // mode?" (SPEC-005 §3: no user exists). func (s *Store) Count(ctx context.Context) (int, error) { var n int if err := s.db.QueryRow(`SELECT count(*) FROM users`).Scan(&n); err != nil { return 0, fmt.Errorf("users: count: %w", err) } return n, nil } // ByEmail returns the user with the given email (recovery lookup, SPEC-007). // The match is case-insensitive. func (s *Store) ByEmail(ctx context.Context, email string) (User, error) { row := s.db.QueryRow(` SELECT user_id, email, display_name, role, manager_user_id, passkey_opt_out, status, created_at FROM users WHERE email = ?`, strings.ToLower(strings.TrimSpace(email))) return scanUser(row) } // PasskeyOptOut implements Users.PasskeyOptOut (D-041): the user's stored // preference about the post-login passkey nudge. func (s *Store) PasskeyOptOut(ctx context.Context, id int64) (bool, error) { var optOut int err := s.db.QueryRow(`SELECT passkey_opt_out FROM users WHERE user_id = ?`, id).Scan(&optOut) if errors.Is(err, sql.ErrNoRows) { return false, ErrNotFound } if err != nil { return false, fmt.Errorf("users: passkey opt-out lookup: %w", err) } return optOut != 0, nil } // SetPasskeyOptOut implements Users.SetPasskeyOptOut (D-041). It is a plain // preference write — no one-admin or role rule applies — and is audited (A11). // The actor comes from the context (ruling R-1); the user is the subject. func (s *Store) SetPasskeyOptOut(ctx context.Context, id int64, optOut bool) error { if _, err := s.Get(ctx, id); err != nil { return err } v := 0 if optOut { v = 1 } if _, err := s.db.Exec(`UPDATE users SET passkey_opt_out = ? WHERE user_id = ?`, v, id); err != nil { return fmt.Errorf("users: set passkey opt-out: %w", err) } s.record(ctx, audit.Event{ Action: "user.passkey_opt_out", Subject: fmt.Sprintf("user:%d", id), Detail: fmt.Sprintf("passkey_opt_out=%t", optOut), }) return nil } // rowScanner is satisfied by *sql.Row and *sql.Rows. type rowScanner interface { Scan(dest ...any) error } func scanUser(row rowScanner) (User, error) { var ( u User role string manager sql.NullInt64 optOut int created string ) err := row.Scan(&u.ID, &u.Email, &u.DisplayName, &role, &manager, &optOut, &u.Status, &created) if errors.Is(err, sql.ErrNoRows) { return User{}, ErrNotFound } if err != nil { return User{}, fmt.Errorf("users: scan: %w", err) } u.Role = Role(role) u.PasskeyOptOut = optOut != 0 if manager.Valid { u.Manager = Some(manager.Int64) } if u.CreatedAt, err = store.ParseTime(created); err != nil { return User{}, fmt.Errorf("users: parse created_at: %w", err) } return u, nil } func nullableID(o Optional[UserID]) any { if !o.Set { return nil } return o.Value } func (s *Store) record(ctx context.Context, e audit.Event) { if s.audit != nil { s.audit.Record(ctx, e) } } // isUniqueViolation reports whether err is a SQLite UNIQUE-constraint failure. func isUniqueViolation(err error) bool { return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed") }