package user import ( "context" "database/sql" "errors" "time" "github.com/noderunners/nip05api/internal/db" ) type Repo struct{ db *db.DB } func NewRepo(d *db.DB) *Repo { return &Repo{db: d} } func parseTime(s sql.NullString) *time.Time { if !s.Valid || s.String == "" { return nil } t, err := time.Parse(time.RFC3339, s.String) if err != nil { t, err = time.Parse("2006-01-02 15:04:05", s.String) if err != nil { return nil } } return &t } func mustParseTime(s string) time.Time { t, err := time.Parse(time.RFC3339, s) if err == nil { return t } t, err = time.Parse("2006-01-02 15:04:05", s) if err == nil { return t } return time.Time{} } func formatTime(t *time.Time) any { if t == nil { return nil } return t.UTC().Format(time.RFC3339) } func scanUser(row interface { Scan(dest ...any) error }) (*User, error) { var u User var sub string var expiresAt, lastSynced, reminderSent, deactivatedAt, createdAt sql.NullString if err := row.Scan( &u.ID, &u.Pubkey, &u.Username, &sub, &expiresAt, &u.IsActive, &u.ManualUsername, &lastSynced, &reminderSent, &deactivatedAt, &createdAt, ); err != nil { return nil, err } u.SubscriptionType = SubscriptionType(sub) u.ExpiresAt = parseTime(expiresAt) u.LastSyncedAt = parseTime(lastSynced) u.ExpiringReminderSentAt = parseTime(reminderSent) u.DeactivatedAt = parseTime(deactivatedAt) if t := parseTime(createdAt); t != nil { u.CreatedAt = *t } return &u, nil } const userCols = `id, pubkey, username, subscription_type, expires_at, is_active, manual_username, last_synced_at, expiring_reminder_sent_at, deactivated_at, created_at` func (r *Repo) GetByPubkey(ctx context.Context, pubkey string) (*User, error) { row := r.db.QueryRowContext(ctx, `SELECT `+userCols+` FROM users WHERE pubkey = ?`, pubkey) u, err := scanUser(row) if errors.Is(err, sql.ErrNoRows) { return nil, ErrUserNotFound } return u, err } func (r *Repo) GetByUsername(ctx context.Context, username string) (*User, error) { row := r.db.QueryRowContext(ctx, `SELECT `+userCols+` FROM users WHERE username = ? COLLATE NOCASE`, username) u, err := scanUser(row) if errors.Is(err, sql.ErrNoRows) { return nil, ErrUserNotFound } return u, err } func (r *Repo) Insert(ctx context.Context, u *User) error { now := time.Now().UTC() res, err := r.db.ExecContext(ctx, `INSERT INTO users (pubkey, username, subscription_type, expires_at, is_active, manual_username, created_at) VALUES (?, ?, ?, ?, 1, ?, ?)`, u.Pubkey, u.Username, string(u.SubscriptionType), formatTime(u.ExpiresAt), u.ManualUsername, now.Format(time.RFC3339)) if err != nil { return err } id, err := res.LastInsertId() if err != nil { return err } u.ID = id u.CreatedAt = now return nil } func (r *Repo) Update(ctx context.Context, u *User) error { _, err := r.db.ExecContext(ctx, `UPDATE users SET username = ?, subscription_type = ?, expires_at = ?, is_active = ?, manual_username = ?, last_synced_at = ?, expiring_reminder_sent_at = ?, deactivated_at = ? WHERE pubkey = ?`, u.Username, string(u.SubscriptionType), formatTime(u.ExpiresAt), u.IsActive, u.ManualUsername, formatTime(u.LastSyncedAt), formatTime(u.ExpiringReminderSentAt), formatTime(u.DeactivatedAt), u.Pubkey) return err } func (r *Repo) Delete(ctx context.Context, pubkey string) error { _, err := r.db.ExecContext(ctx, `DELETE FROM users WHERE pubkey = ?`, pubkey) return err } // SetActiveExpiry sets a user's expires_at to an absolute value and reactivates // them. Idempotent: applying the same input twice produces the same end state. func (r *Repo) SetActiveExpiry(ctx context.Context, pubkey string, sub SubscriptionType, expiresAt *time.Time) error { _, err := r.db.ExecContext(ctx, `UPDATE users SET is_active = 1, deactivated_at = NULL, expiring_reminder_sent_at = NULL, subscription_type = ?, expires_at = ? WHERE pubkey = ?`, string(sub), formatTime(expiresAt), pubkey) return err }