package expiry import ( "context" "log/slog" "strconv" "time" "github.com/noderunners/nip05api/internal/audit" "github.com/noderunners/nip05api/internal/dm" "github.com/noderunners/nip05api/internal/nostr" "github.com/noderunners/nip05api/internal/user" "github.com/noderunners/nip05api/internal/webhook" ) type Worker struct { users *user.Service dms *dm.Service hooks *webhook.Service audit *audit.Logger domain string frontend string graceDays int reminderDays []int cronHourUTC int clock func() time.Time } func NewWorker(users *user.Service, dms *dm.Service, hooks *webhook.Service, aud *audit.Logger, domain, frontend string, graceDays int, reminderDays []int, cronHourUTC int) *Worker { if len(reminderDays) == 0 { reminderDays = []int{7} } return &Worker{ users: users, dms: dms, hooks: hooks, audit: aud, domain: domain, frontend: frontend, graceDays: graceDays, reminderDays: reminderDays, cronHourUTC: cronHourUTC, clock: func() time.Time { return time.Now().UTC() }, } } func (w *Worker) WithClock(c func() time.Time) *Worker { w.clock = c; return w } func (w *Worker) Run(ctx context.Context) { w.RunOnce(ctx) for { next := w.nextRun() timer := time.NewTimer(time.Until(next)) select { case <-ctx.Done(): timer.Stop() return case <-timer.C: w.RunOnce(ctx) } } } func (w *Worker) nextRun() time.Time { now := w.clock() target := time.Date(now.Year(), now.Month(), now.Day(), w.cronHourUTC, 0, 0, 0, time.UTC) if !target.After(now) { target = target.Add(24 * time.Hour) } return target } func (w *Worker) RunOnce(ctx context.Context) { now := w.clock() w.processReminders(ctx, now) w.processExpirations(ctx, now) w.processGraceCleanup(ctx, now) } func (w *Worker) processReminders(ctx context.Context, now time.Time) { for _, days := range w.reminderDays { users, err := w.users.Repo().ListPendingReminders(ctx, days, now) if err != nil { slog.Error("expiry reminders list", "err", err) continue } for _, u := range users { vars := buildVars(u, w.domain, w.frontend, days) if err := w.dms.Send(ctx, dm.EventExpiringSoon, u.Pubkey, vars); err != nil { slog.Error("expiry reminder dm", "pubkey", u.Pubkey, "err", err) continue } ts := now u.ExpiringReminderSentAt = &ts if err := w.users.Repo().Update(ctx, u); err != nil { slog.Error("expiry reminder update", "pubkey", u.Pubkey, "err", err) } } } } func (w *Worker) processExpirations(ctx context.Context, now time.Time) { users, err := w.users.Repo().ListExpired(ctx, now) if err != nil { slog.Error("expiry list", "err", err) return } for _, u := range users { ts := now u.IsActive = false u.DeactivatedAt = &ts if err := w.users.Repo().Update(ctx, u); err != nil { slog.Error("expiry deactivate", "pubkey", u.Pubkey, "err", err) continue } vars := buildVars(u, w.domain, w.frontend, 0) vars["grace_days"] = strconv.Itoa(w.graceDays) _ = w.dms.Send(ctx, dm.EventExpired, u.Pubkey, vars) _ = w.hooks.Enqueue(ctx, webhook.EventUserRemoved, hookData(u, "expired")) w.audit.Log(ctx, audit.ActionUserExpired, audit.ActorSystem, u.Pubkey, map[string]any{ "username": u.Username, }) } } func (w *Worker) processGraceCleanup(ctx context.Context, now time.Time) { cutoff := now.Add(-time.Duration(w.graceDays) * 24 * time.Hour) users, err := w.users.Repo().ListGraceExpired(ctx, cutoff) if err != nil { slog.Error("grace list", "err", err) return } for _, u := range users { if err := w.users.Repo().Delete(ctx, u.Pubkey); err != nil { slog.Error("grace delete", "pubkey", u.Pubkey, "err", err) continue } _ = w.hooks.Enqueue(ctx, webhook.EventUserRemoved, hookData(u, "grace_cleanup")) w.audit.Log(ctx, audit.ActionUserGracePurged, audit.ActorSystem, u.Pubkey, map[string]any{ "username": u.Username, }) } } func buildVars(u *user.User, domain, frontend string, daysRemaining int) map[string]string { expires := "lifetime" if u.ExpiresAt != nil { expires = u.ExpiresAt.Format("2006-01-02") } return map[string]string{ "username": u.Username, "npub": nostr.HexToNpub(u.Pubkey), "pubkey": u.Pubkey, "domain": domain, "expires_at": expires, "days_remaining": strconv.Itoa(daysRemaining), "frontend_url": frontend, "subscription_type": string(u.SubscriptionType), } } func hookData(u *user.User, reason string) map[string]any { d := map[string]any{ "pubkey": u.Pubkey, "npub": nostr.HexToNpub(u.Pubkey), "username": u.Username, "subscription_type": string(u.SubscriptionType), "reason": reason, } if u.ExpiresAt != nil { d["expires_at"] = u.ExpiresAt.UTC().Format(time.RFC3339) } return d }