Files
Nip-05-api/internal/user/service.go
Michilis fe2b95258d feat: admin endpoints to reset username sync flags
Add POST /v1/admin/users/{pubkey}/reset-username and
POST /v1/admin/users/reset-usernames to clear manual_username
and last_synced_at so nostr profile sync re-evaluates users.
Includes OpenAPI docs, audit actions, and tests.
2026-05-06 19:31:13 +00:00

152 lines
4.2 KiB
Go

package user
import (
"context"
"errors"
"time"
)
type Service struct {
repo *Repo
reserved []string
}
func NewService(repo *Repo, reserved []string) *Service {
return &Service{repo: repo, reserved: reserved}
}
func (s *Service) Repo() *Repo { return s.repo }
func (s *Service) Reserved() []string { return s.reserved }
// IsAvailable returns true if no active/in-grace user has the username.
func (s *Service) IsAvailable(ctx context.Context, username string) (bool, error) {
if err := ValidateUsername(username, s.reserved); err != nil {
return false, err
}
u, err := s.repo.GetByUsername(ctx, NormalizeUsername(username))
if errors.Is(err, ErrUserNotFound) {
return true, nil
}
if err != nil {
return false, err
}
_ = u
return false, nil
}
// CreateOrActivate inserts a new active user. Caller is responsible for transactional
// concerns (e.g. payments worker uses this within a tx).
func (s *Service) CreateOrActivate(ctx context.Context, pubkey, username string, sub SubscriptionType, years int, manual bool) (*User, error) {
username = NormalizeUsername(username)
if username != "" {
if err := ValidateUsername(username, s.reserved); err != nil {
return nil, err
}
}
now := time.Now().UTC()
expiresAt := computeExpiry(sub, years, time.Time{}, now)
u := &User{
Pubkey: pubkey,
Username: username,
SubscriptionType: sub,
ExpiresAt: expiresAt,
IsActive: true,
ManualUsername: manual,
}
if err := s.repo.Insert(ctx, u); err != nil {
return nil, err
}
return u, nil
}
// Renew extends an existing user. Used by payments worker and admin extend.
func (s *Service) Renew(ctx context.Context, u *User, sub SubscriptionType, years int) error {
now := time.Now().UTC()
var current time.Time
if u.ExpiresAt != nil {
current = *u.ExpiresAt
}
u.SubscriptionType = sub
u.ExpiresAt = computeExpiry(sub, years, current, now)
u.IsActive = true
u.DeactivatedAt = nil
u.ExpiringReminderSentAt = nil
return s.repo.Update(ctx, u)
}
func (s *Service) SetUsername(ctx context.Context, pubkey, username string) (*User, error) {
username = NormalizeUsername(username)
if err := ValidateUsername(username, s.reserved); err != nil {
return nil, err
}
u, err := s.repo.GetByPubkey(ctx, pubkey)
if err != nil {
return nil, err
}
if existing, err := s.repo.GetByUsername(ctx, username); err == nil && existing.Pubkey != pubkey {
return nil, ErrUsernameTaken
}
u.Username = username
u.ManualUsername = true
if err := s.repo.Update(ctx, u); err != nil {
return nil, err
}
return u, nil
}
func (s *Service) Delete(ctx context.Context, pubkey string) error {
return s.repo.Delete(ctx, pubkey)
}
// ResetUsername clears the manual_username pin and last_synced_at cooldown for
// a single user so the next profile sync cycle re-evaluates their kind:0
// metadata. The stored username is left untouched until the worker overwrites
// it. Returns the updated user.
func (s *Service) ResetUsername(ctx context.Context, pubkey string) (*User, error) {
u, err := s.repo.GetByPubkey(ctx, pubkey)
if err != nil {
return nil, err
}
u.ManualUsername = false
u.LastSyncedAt = nil
if err := s.repo.Update(ctx, u); err != nil {
return nil, err
}
return u, nil
}
// ResetAllUsernames clears manual_username and last_synced_at for every active
// user. Returns the number of affected rows.
func (s *Service) ResetAllUsernames(ctx context.Context) (int64, error) {
return s.repo.ResetAllSyncFlags(ctx)
}
// computeExpiry returns *time.Time (nil for lifetime).
func computeExpiry(sub SubscriptionType, years int, current time.Time, now time.Time) *time.Time {
if sub == SubLifetime {
return nil
}
var cur time.Time
if !current.IsZero() {
cur = current
}
return YearlyTargetExpiry(&cur, years, now)
}
// YearlyTargetExpiry computes the new expiry for a yearly subscription using
// effective_start = max(now, current_expiry); new_expiry = effective_start + years.
// currentExpiry may be nil or zero for first-time purchases.
func YearlyTargetExpiry(currentExpiry *time.Time, years int, now time.Time) *time.Time {
if years <= 0 {
years = 1
}
base := now
if currentExpiry != nil && currentExpiry.After(base) {
base = *currentExpiry
}
t := base.AddDate(years, 0, 0)
return &t
}