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.
152 lines
4.2 KiB
Go
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
|
|
}
|