first commit
This commit is contained in:
175
internal/expiry/worker.go
Normal file
175
internal/expiry/worker.go
Normal file
@@ -0,0 +1,175 @@
|
||||
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
|
||||
}
|
||||
204
internal/expiry/worker_test.go
Normal file
204
internal/expiry/worker_test.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package expiry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/noderunners/nip05api/internal/audit"
|
||||
"github.com/noderunners/nip05api/internal/db"
|
||||
"github.com/noderunners/nip05api/internal/dm"
|
||||
"github.com/noderunners/nip05api/internal/messages"
|
||||
"github.com/noderunners/nip05api/internal/user"
|
||||
"github.com/noderunners/nip05api/internal/webhook"
|
||||
)
|
||||
|
||||
const testHex = "0e8c41ebcd55a8d8db2e0a8c3a4b9c5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c"
|
||||
|
||||
func newTestStack(t *testing.T) (*db.DB, *user.Service, *dm.Service, *webhook.Service, *audit.Logger) {
|
||||
t.Helper()
|
||||
d, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.Migrate(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { d.Close() })
|
||||
|
||||
tmpls, _ := messages.Load("/nonexistent.yaml")
|
||||
users := user.NewService(user.NewRepo(d), nil)
|
||||
dms := dm.NewService(dm.NewRepo(d), tmpls, true)
|
||||
hooks := webhook.NewService(webhook.NewRepo(d), "test.local", true)
|
||||
return d, users, dms, hooks, audit.New(d)
|
||||
}
|
||||
|
||||
func TestExpiry_Reminder(t *testing.T) {
|
||||
d, users, dms, hooks, aud := newTestStack(t)
|
||||
now := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC)
|
||||
expires := now.Add(7 * 24 * time.Hour)
|
||||
|
||||
u := &user.User{
|
||||
Pubkey: testHex,
|
||||
Username: "alice",
|
||||
SubscriptionType: user.SubYearly,
|
||||
ExpiresAt: &expires,
|
||||
IsActive: true,
|
||||
}
|
||||
if err := users.Repo().Insert(context.Background(), u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := NewWorker(users, dms, hooks, aud, "test.local", "https://test.local",
|
||||
30, []int{7}, 9).WithClock(func() time.Time { return now })
|
||||
w.RunOnce(context.Background())
|
||||
|
||||
var dmCount int
|
||||
if err := d.QueryRowContext(context.Background(),
|
||||
`SELECT COUNT(*) FROM dm_outbox WHERE event_type = 'expiring_soon'`).Scan(&dmCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dmCount != 1 {
|
||||
t.Errorf("expected 1 expiring_soon DM, got %d", dmCount)
|
||||
}
|
||||
|
||||
// Reminder flag should be set, so a re-run does not double-send.
|
||||
w.RunOnce(context.Background())
|
||||
if err := d.QueryRowContext(context.Background(),
|
||||
`SELECT COUNT(*) FROM dm_outbox WHERE event_type = 'expiring_soon'`).Scan(&dmCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dmCount != 1 {
|
||||
t.Errorf("re-run should not double-fire, got %d", dmCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiry_Expiration(t *testing.T) {
|
||||
d, users, dms, hooks, aud := newTestStack(t)
|
||||
now := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC)
|
||||
expired := now.Add(-1 * time.Hour)
|
||||
|
||||
u := &user.User{
|
||||
Pubkey: testHex,
|
||||
Username: "bob",
|
||||
SubscriptionType: user.SubYearly,
|
||||
ExpiresAt: &expired,
|
||||
IsActive: true,
|
||||
}
|
||||
if err := users.Repo().Insert(context.Background(), u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := NewWorker(users, dms, hooks, aud, "test.local", "https://test.local",
|
||||
30, []int{7}, 9).WithClock(func() time.Time { return now })
|
||||
w.RunOnce(context.Background())
|
||||
|
||||
got, err := users.Repo().GetByPubkey(context.Background(), testHex)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.IsActive {
|
||||
t.Error("expected user deactivated")
|
||||
}
|
||||
if got.DeactivatedAt == nil {
|
||||
t.Error("expected deactivated_at set")
|
||||
}
|
||||
|
||||
var dmCount int
|
||||
if err := d.QueryRowContext(context.Background(),
|
||||
`SELECT COUNT(*) FROM dm_outbox WHERE event_type = 'expired'`).Scan(&dmCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dmCount != 1 {
|
||||
t.Errorf("expected 1 expired DM, got %d", dmCount)
|
||||
}
|
||||
|
||||
var hookCount int
|
||||
if err := d.QueryRowContext(context.Background(),
|
||||
`SELECT COUNT(*) FROM webhook_outbox WHERE event_type = 'user.removed'`).Scan(&hookCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hookCount != 1 {
|
||||
t.Errorf("expected 1 user.removed webhook, got %d", hookCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiry_GraceCleanup(t *testing.T) {
|
||||
d, users, dms, hooks, aud := newTestStack(t)
|
||||
now := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC)
|
||||
deactivated := now.Add(-31 * 24 * time.Hour)
|
||||
expired := now.Add(-32 * 24 * time.Hour)
|
||||
|
||||
u := &user.User{
|
||||
Pubkey: testHex,
|
||||
Username: "charlie",
|
||||
SubscriptionType: user.SubYearly,
|
||||
ExpiresAt: &expired,
|
||||
IsActive: false,
|
||||
DeactivatedAt: &deactivated,
|
||||
}
|
||||
if err := users.Repo().Insert(context.Background(), u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Insert sets is_active=1 by default; force it inactive.
|
||||
if err := users.Repo().Update(context.Background(), u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := NewWorker(users, dms, hooks, aud, "test.local", "https://test.local",
|
||||
30, []int{7}, 9).WithClock(func() time.Time { return now })
|
||||
w.RunOnce(context.Background())
|
||||
|
||||
// User should be hard-deleted.
|
||||
_, err := users.Repo().GetByPubkey(context.Background(), testHex)
|
||||
if err == nil {
|
||||
t.Error("expected user deleted after grace cleanup")
|
||||
}
|
||||
|
||||
// Audit log should record the purge.
|
||||
var auditCount int
|
||||
if err := d.QueryRowContext(context.Background(),
|
||||
`SELECT COUNT(*) FROM audit_log WHERE action = 'user.grace_purged'`).Scan(&auditCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if auditCount != 1 {
|
||||
t.Errorf("expected 1 grace_purged audit, got %d", auditCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpiry_LifetimeUserNotAffected(t *testing.T) {
|
||||
d, users, dms, hooks, aud := newTestStack(t)
|
||||
now := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
u := &user.User{
|
||||
Pubkey: testHex,
|
||||
Username: "lifetime",
|
||||
SubscriptionType: user.SubLifetime,
|
||||
IsActive: true,
|
||||
}
|
||||
if err := users.Repo().Insert(context.Background(), u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := NewWorker(users, dms, hooks, aud, "test.local", "https://test.local",
|
||||
30, []int{7}, 9).WithClock(func() time.Time { return now })
|
||||
w.RunOnce(context.Background())
|
||||
|
||||
got, err := users.Repo().GetByPubkey(context.Background(), testHex)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.IsActive {
|
||||
t.Error("lifetime user should not be deactivated")
|
||||
}
|
||||
|
||||
var dmCount int
|
||||
if err := d.QueryRowContext(context.Background(),
|
||||
`SELECT COUNT(*) FROM dm_outbox`).Scan(&dmCount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dmCount != 0 {
|
||||
t.Errorf("lifetime user should receive no DM, got %d", dmCount)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user