package workers import ( "context" "time" "github.com/google/uuid" "go.uber.org/zap" "gorm.io/gorm" ) // HardDeleteWorker performs final GDPR cleanup for users past recovery deadline (v0.10.8 F065) // Runs periodically to irreversibly anonymize accounts that were soft-deleted 30+ days ago type HardDeleteWorker struct { db *gorm.DB logger *zap.Logger interval time.Duration stopChan chan struct{} running bool } // NewHardDeleteWorker creates a new hard delete worker func NewHardDeleteWorker(db *gorm.DB, logger *zap.Logger, interval time.Duration) *HardDeleteWorker { if logger == nil { logger = zap.NewNop() } if interval <= 0 { interval = 24 * time.Hour } return &HardDeleteWorker{ db: db, logger: logger, interval: interval, stopChan: make(chan struct{}), running: false, } } // Start runs the worker periodically func (w *HardDeleteWorker) Start(ctx context.Context) { if w.running { w.logger.Warn("Hard delete worker is already running") return } w.running = true w.logger.Info("Starting hard delete worker", zap.Duration("interval", w.interval)) go w.runOnce(ctx) ticker := time.NewTicker(w.interval) defer ticker.Stop() for { select { case <-ctx.Done(): w.logger.Info("Stopping hard delete worker") w.running = false return case <-w.stopChan: w.logger.Info("Stopping hard delete worker (stop requested)") w.running = false return case <-ticker.C: go w.runOnce(ctx) } } } // Stop stops the worker func (w *HardDeleteWorker) Stop() { if !w.running { return } close(w.stopChan) } // runOnce executes one pass of hard delete / final anonymization func (w *HardDeleteWorker) runOnce(ctx context.Context) { logger := w.logger.With(zap.String("worker", "hard_delete")) runCtx, cancel := context.WithTimeout(ctx, 30*time.Minute) defer cancel() var userIDs []uuid.UUID if err := w.db.WithContext(runCtx).Raw(` SELECT id FROM users WHERE deleted_at IS NOT NULL AND recovery_deadline IS NOT NULL AND recovery_deadline < NOW() `).Scan(&userIDs).Error; err != nil { logger.Error("Failed to query users for hard delete", zap.Error(err)) return } if len(userIDs) == 0 { logger.Debug("No users eligible for hard delete") return } logger.Info("Processing hard delete", zap.Int("count", len(userIDs))) for _, id := range userIDs { // Final anonymization pass (ORIGIN §19.3) - ensure no PII remains res := w.db.WithContext(runCtx).Exec(` UPDATE users SET password_hash = '', first_name = NULL, last_name = NULL, bio = '', location = '', avatar = '', banner_url = '', recovery_deadline = NULL, updated_at = NOW() WHERE id = ? AND deleted_at IS NOT NULL `, id) if res.Error != nil { logger.Error("Failed to anonymize user", zap.Error(res.Error), zap.String("user_id", id.String())) continue } // Delete user_profiles (may contain PII) w.db.WithContext(runCtx).Exec("DELETE FROM user_profiles WHERE user_id = ?", id) // SECURITY(HIGH-007): RGPD — clean additional PII-containing tables w.db.WithContext(runCtx).Exec("DELETE FROM user_sessions WHERE user_id = ?", id) w.db.WithContext(runCtx).Exec("DELETE FROM user_settings WHERE user_id = ?", id) w.db.WithContext(runCtx).Exec("DELETE FROM user_follows WHERE follower_id = ? OR following_id = ?", id, id) w.db.WithContext(runCtx).Exec("DELETE FROM notifications WHERE user_id = ? OR actor_id = ?", id, id) w.db.WithContext(runCtx).Exec("UPDATE audit_logs SET user_id = NULL, ip_address = NULL WHERE user_id = ?", id) // TODO(HIGH-007): Clean Redis cache keys (user:{id}:*) and Elasticsearch user documents. // Requires injecting Redis/ES clients into HardDeleteWorker. logger.Info("Hard delete completed", zap.String("user_id", id.String())) } }