veza/veza-backend-api/internal/workers/hard_delete_worker.go
senke 19fec9e40a
Some checks failed
Backend API CI / test-integration (push) Failing after 0s
Frontend CI / test (push) Failing after 0s
Storybook Audit / Build & audit Storybook (push) Failing after 0s
Backend API CI / test-unit (push) Failing after 0s
feat(gdpr): v0.10.8 portabilité données - export ZIP async, suppression compte, hard delete cron
- Export: table data_exports, POST /me/export (202), GET /me/exports, messages+playback_history
- Notification email quand ZIP prêt, rate limit 3/jour
- Suppression: keep_public_tracks, anonymisation PII complète (users, user_profiles)
- HardDeleteWorker: final anonymization après 30 jours
- Frontend: POST export, checkbox keep_public_tracks
- MSW handlers pour Storybook
2026-03-10 13:57:04 +01:00

122 lines
3 KiB
Go

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)
logger.Info("Hard delete completed", zap.String("user_id", id.String()))
}
}