package storage import ( "context" "database/sql" "errors" "fmt" "strings" "time" "github.com/google/uuid" ) // User represents a tenant of the librenotes service. type User struct { ID string Email string CreatedAt time.Time LastLoginAt *time.Time StoragePath string } // ErrNotFound indicates that a user lookup did not match any row. var ErrNotFound = errors.New("user not found") // ErrEmailTaken indicates that the given email already maps to a user. var ErrEmailTaken = errors.New("email already registered") // UserStore is the persistence interface for users. Implementations are // expected to be safe for concurrent use. type UserStore struct { db *sql.DB } // NewUserStore wraps a database handle. func NewUserStore(db *sql.DB) *UserStore { return &UserStore{db: db} } // Create inserts a new user. The ID is generated as a UUIDv4 if empty. // Email is normalised (trimmed + lowercased) before insertion. The // StoragePath is preserved as given so the caller can decide on the // filesystem layout. func (s *UserStore) Create(ctx context.Context, u User) (User, error) { if u.ID == "" { u.ID = uuid.NewString() } u.Email = normaliseEmail(u.Email) if u.Email == "" { return User{}, fmt.Errorf("email required") } if u.StoragePath == "" { return User{}, fmt.Errorf("storage_path required") } if u.CreatedAt.IsZero() { u.CreatedAt = time.Now().UTC() } const q = `INSERT INTO users (id, email, created_at, last_login_at, storage_path) VALUES (?, ?, ?, ?, ?)` var lastLogin sql.NullInt64 if u.LastLoginAt != nil { lastLogin = sql.NullInt64{Int64: u.LastLoginAt.Unix(), Valid: true} } _, err := s.db.ExecContext(ctx, q, u.ID, u.Email, u.CreatedAt.Unix(), lastLogin, u.StoragePath) if err != nil { if isUniqueErr(err) { return User{}, ErrEmailTaken } return User{}, fmt.Errorf("insert user: %w", err) } return u, nil } // GetByID fetches a user by primary key. func (s *UserStore) GetByID(ctx context.Context, id string) (User, error) { return s.scanOne(ctx, `SELECT id, email, created_at, last_login_at, storage_path FROM users WHERE id = ?`, id) } // GetByEmail fetches a user by their (normalised) email. func (s *UserStore) GetByEmail(ctx context.Context, email string) (User, error) { return s.scanOne(ctx, `SELECT id, email, created_at, last_login_at, storage_path FROM users WHERE email = ?`, normaliseEmail(email)) } // SetStoragePath updates the per-user storage path. Used during the // first-login auto-create flow once the UUID is known. func (s *UserStore) SetStoragePath(ctx context.Context, id, path string) error { res, err := s.db.ExecContext(ctx, `UPDATE users SET storage_path = ? WHERE id = ?`, path, id) if err != nil { return fmt.Errorf("update storage_path: %w", err) } n, _ := res.RowsAffected() if n == 0 { return ErrNotFound } return nil } // UpdateLastLogin records a successful login at the given instant. func (s *UserStore) UpdateLastLogin(ctx context.Context, id string, at time.Time) error { res, err := s.db.ExecContext(ctx, `UPDATE users SET last_login_at = ? WHERE id = ?`, at.Unix(), id) if err != nil { return fmt.Errorf("update last_login: %w", err) } n, _ := res.RowsAffected() if n == 0 { return ErrNotFound } return nil } // Delete removes a user row. Returns ErrNotFound if no row matched. func (s *UserStore) Delete(ctx context.Context, id string) error { res, err := s.db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id) if err != nil { return fmt.Errorf("delete user: %w", err) } n, _ := res.RowsAffected() if n == 0 { return ErrNotFound } return nil } func (s *UserStore) scanOne(ctx context.Context, q string, args ...any) (User, error) { var u User var created int64 var lastLogin sql.NullInt64 err := s.db.QueryRowContext(ctx, q, args...).Scan(&u.ID, &u.Email, &created, &lastLogin, &u.StoragePath) if errors.Is(err, sql.ErrNoRows) { return User{}, ErrNotFound } if err != nil { return User{}, fmt.Errorf("scan user: %w", err) } u.CreatedAt = time.Unix(created, 0).UTC() if lastLogin.Valid { t := time.Unix(lastLogin.Int64, 0).UTC() u.LastLoginAt = &t } return u, nil } func normaliseEmail(s string) string { return strings.ToLower(strings.TrimSpace(s)) } func isUniqueErr(err error) bool { // modernc.org/sqlite returns errors whose Error() text contains // "UNIQUE constraint failed". This is stable across versions. return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed") }