diff --git a/backend/handlers/scheduling/default-hours.go b/backend/handlers/scheduling/default-hours.go index 76b7808..fe14442 100644 --- a/backend/handlers/scheduling/default-hours.go +++ b/backend/handlers/scheduling/default-hours.go @@ -358,6 +358,11 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) { log.Printf("Failed to cleanup old idempotency keys: %v", err) } + // Clean up old name history (6+ months) + if err := CleanupOldNameHistory(r.Context()); err != nil { + log.Printf("Failed to cleanup old name history: %v", err) + } + // Load default hours defaultMap := map[int]DefaultHours{} defRows, _ := db.DB.Query(r.Context(), `SELECT weekday, start_time::text, end_time::text, is_open FROM working_hours`) diff --git a/backend/handlers/scheduling/time-blockers.go b/backend/handlers/scheduling/time-blockers.go index 917a1f8..166434e 100644 --- a/backend/handlers/scheduling/time-blockers.go +++ b/backend/handlers/scheduling/time-blockers.go @@ -955,3 +955,18 @@ func CleanupOldIdempotencyKeys(ctx context.Context) error { return nil } + +// CleanupOldNameHistory removes name_history entries older than 6 months. +// WHY: GDPR requires data minimization — name change history doesn't need +// to be retained indefinitely. 6 months provides a reasonable window for +// displaying former names on booking receipts and admin views. +func CleanupOldNameHistory(ctx context.Context) error { + _, err := db.DB.Exec(ctx, ` + DELETE FROM name_history + WHERE changed_at < NOW() - INTERVAL '6 months' + `) + if err != nil { + return fmt.Errorf("failed to cleanup old name history: %w", err) + } + return nil +} diff --git a/backend/handlers/scheduling/time_blockers_test.go b/backend/handlers/scheduling/time_blockers_test.go index 9d31853..6e4aa87 100644 --- a/backend/handlers/scheduling/time_blockers_test.go +++ b/backend/handlers/scheduling/time_blockers_test.go @@ -2830,3 +2830,119 @@ func TestCleanupExpiredLoyaltyRedemptions_Empty(t *testing.T) { t.Fatalf("CleanupExpiredLoyaltyRedemptions failed: %v", err) } } + +// ============================================================================= +// CleanupOldNameHistory Tests +// ============================================================================= + +func TestCleanupOldNameHistory_DeletesOldEntries(t *testing.T) { + resetTestData(t) + ctx := context.Background() + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + + // Insert an old name_history entry (7 months ago) + _, err = db.DB.Exec(ctx, ` + INSERT INTO name_history (user_id, previous_first_name, previous_last_name, changed_at) + VALUES ($1, 'Old', 'Name', NOW() - INTERVAL '7 months') + `, userID) + if err != nil { + t.Fatalf("failed to insert old name_history: %v", err) + } + + // Insert a recent name_history entry (1 month ago — should be preserved) + _, err = db.DB.Exec(ctx, ` + INSERT INTO name_history (user_id, previous_first_name, previous_last_name, changed_at) + VALUES ($1, 'Recent', 'Name', NOW() - INTERVAL '1 month') + `, userID) + if err != nil { + t.Fatalf("failed to insert recent name_history: %v", err) + } + + err = CleanupOldNameHistory(ctx) + if err != nil { + t.Fatalf("CleanupOldNameHistory failed: %v", err) + } + + // Verify old entry was deleted + var oldCount int + err = db.DB.QueryRow(ctx, `SELECT COUNT(*) FROM name_history WHERE previous_first_name = 'Old'`).Scan(&oldCount) + if err != nil { + t.Fatalf("failed to count old entries: %v", err) + } + if oldCount != 0 { + t.Errorf("expected old name_history entry to be deleted, got %d entries", oldCount) + } + + // Verify recent entry was preserved + var recentCount int + err = db.DB.QueryRow(ctx, `SELECT COUNT(*) FROM name_history WHERE previous_first_name = 'Recent'`).Scan(&recentCount) + if err != nil { + t.Fatalf("failed to count recent entries: %v", err) + } + if recentCount != 1 { + t.Errorf("expected recent name_history entry to be preserved, got %d entries", recentCount) + } + + // Verify total entries: 1 preserved (recent), old was deleted + var totalCount int + err = db.DB.QueryRow(ctx, `SELECT COUNT(*) FROM name_history`).Scan(&totalCount) + if err != nil { + t.Fatalf("failed to count total entries: %v", err) + } + if totalCount != 1 { + t.Errorf("expected 1 total entry (only recent preserved), got %d", totalCount) + } +} + +func TestCleanupOldNameHistory_EmptyTable(t *testing.T) { + resetTestData(t) + + err := CleanupOldNameHistory(context.Background()) + if err != nil { + t.Fatalf("CleanupOldNameHistory failed: %v", err) + } +} + +func TestCleanupOldNameHistory_Idempotent(t *testing.T) { + resetTestData(t) + ctx := context.Background() + + userID, err := fixtures.CreateTestUser(db.DB) + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + + // Insert an old entry + _, err = db.DB.Exec(ctx, ` + INSERT INTO name_history (user_id, previous_first_name, previous_last_name, changed_at) + VALUES ($1, 'Old', 'Name', NOW() - INTERVAL '7 months') + `, userID) + if err != nil { + t.Fatalf("failed to insert name_history: %v", err) + } + + // Run cleanup twice + err = CleanupOldNameHistory(ctx) + if err != nil { + t.Fatalf("first CleanupOldNameHistory failed: %v", err) + } + + err = CleanupOldNameHistory(ctx) + if err != nil { + t.Fatalf("second CleanupOldNameHistory failed: %v", err) + } + + // Verify no errors and still clean + var count int + err = db.DB.QueryRow(ctx, `SELECT COUNT(*) FROM name_history`).Scan(&count) + if err != nil { + t.Fatalf("failed to count: %v", err) + } + if count != 0 { + t.Errorf("expected 0 entries after idempotent cleanup, got %d", count) + } +}