package retention import ( "context" "fmt" "log" "sync" "time" "chat-switchboard/storage" "chat-switchboard/store" ) const DefaultInterval = 1 * time.Hour // ScannerConfig holds startup configuration. type ScannerConfig struct { Interval time.Duration } // Scanner periodically purges channels whose purge_after timestamp has passed. // Channels with global/team provider scopes are archived with a TTL by // DeleteChannel; this scanner performs the deferred hard-delete. type Scanner struct { stores store.Stores objStore storage.ObjectStore wg sync.WaitGroup stopCh chan struct{} interval time.Duration } // NewScanner creates a retention scanner. Call Start() to begin. func NewScanner(stores store.Stores, objStore storage.ObjectStore, cfg ScannerConfig) *Scanner { interval := cfg.Interval if interval <= 0 { interval = DefaultInterval } return &Scanner{ stores: stores, objStore: objStore, stopCh: make(chan struct{}), interval: interval, } } // Start begins the scan loop in a background goroutine. func (sc *Scanner) Start() { sc.wg.Add(1) go func() { defer sc.wg.Done() sc.loop() }() log.Printf("[retention] scanner started (interval=%s)", sc.interval) } // Stop signals the scanner to stop and waits for in-flight work to drain. func (sc *Scanner) Stop() { close(sc.stopCh) sc.wg.Wait() log.Printf("[retention] scanner stopped") } func (sc *Scanner) loop() { ticker := time.NewTicker(sc.interval) defer ticker.Stop() for { select { case <-sc.stopCh: return case <-ticker.C: sc.tick() } } } func (sc *Scanner) tick() { ctx := context.Background() // If TTL is 0 the feature is disabled — nothing to purge ttl := sc.retentionTTL(ctx) if ttl <= 0 { return } ids, err := sc.stores.Channels.ListPurgeable(ctx) if err != nil { log.Printf("[retention] ListPurgeable error: %v", err) return } if len(ids) == 0 { return } log.Printf("[retention] purging %d channel(s)", len(ids)) for _, id := range ids { // Clean up storage files first if sc.objStore != nil { prefix := fmt.Sprintf("files/%s", id) if err := sc.objStore.DeletePrefix(ctx, prefix); err != nil { log.Printf("[retention] storage cleanup for %s failed: %v", id, err) } } // Hard delete (Purge verifies is_archived) if err := sc.stores.Channels.Purge(ctx, id); err != nil { log.Printf("[retention] purge %s failed: %v", id, err) } } } func (sc *Scanner) retentionTTL(ctx context.Context) int { cfg, err := sc.stores.GlobalConfig.Get(ctx, "retention_ttl_days") if err != nil { return 0 } if v, ok := cfg["value"].(float64); ok { return int(v) } return 0 }