feat: dual TTL reservation cleanup and guest account anonymization

CleanupOldReservations now handles four reservation types:
logged-in (1hr), anonymous (10min), admin-walkin (10min), admin-callin (1hr).

AnonymizeStaleGuestAccounts scrubs PII from guest accounts whose last booking
was 6+ months ago and who have no pending/confirmed bookings. Financial records
remain intact — only personal data is wiped (UK GDPR compliance).
Triggered on every GetAvailableHours call.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-04-30 11:44:51 +01:00
co-authored by Sisyphus
parent 70cba5e412
commit 4f173b04dc
2 changed files with 52 additions and 15 deletions
@@ -275,6 +275,11 @@ func GetAvailableHours(w http.ResponseWriter, r *http.Request) {
log.Printf("Failed to cleanup old reservations: %v", err) log.Printf("Failed to cleanup old reservations: %v", err)
} }
// Anonymize stale guest accounts (6+ months after last booking)
if err := AnonymizeStaleGuestAccounts(r.Context()); err != nil {
log.Printf("Failed to anonymize stale guest accounts: %v", err)
}
// Load default hours // Load default hours
defaultMap := map[int]DefaultHours{} defaultMap := map[int]DefaultHours{}
defRows, _ := db.DB.Query(r.Context(), `SELECT weekday, start_time::text, end_time::text, is_open FROM working_hours`) defRows, _ := db.DB.Query(r.Context(), `SELECT weekday, start_time::text, end_time::text, is_open FROM working_hours`)
+33 -1
View File
@@ -336,6 +336,8 @@ func CheckTimeBlockerOverlap(ctx context.Context, startTime, endTime time.Time)
// CleanupOldReservations deletes expired reservations: // CleanupOldReservations deletes expired reservations:
// - Logged-in (RESERVATION:user): older than 1 hour // - Logged-in (RESERVATION:user): older than 1 hour
// - Anonymous (RESERVATION:anon): older than 10 minutes // - Anonymous (RESERVATION:anon): older than 10 minutes
// - Admin walk-in (RESERVATION:admin:walkin:%): older than 10 minutes
// - Admin call-in (RESERVATION:admin:callin:%): older than 1 hour
func CleanupOldReservations(ctx context.Context) error { func CleanupOldReservations(ctx context.Context) error {
oneHourAgo := time.Now().Add(-1 * time.Hour) oneHourAgo := time.Now().Add(-1 * time.Hour)
tenMinutesAgo := time.Now().Add(-10 * time.Minute) tenMinutesAgo := time.Now().Add(-10 * time.Minute)
@@ -344,6 +346,36 @@ func CleanupOldReservations(ctx context.Context) error {
DELETE FROM time_blockers DELETE FROM time_blockers
WHERE (description LIKE 'RESERVATION:user:%' AND created_at < $1) WHERE (description LIKE 'RESERVATION:user:%' AND created_at < $1)
OR (description LIKE 'RESERVATION:anon:%' AND created_at < $2) OR (description LIKE 'RESERVATION:anon:%' AND created_at < $2)
`, oneHourAgo, tenMinutesAgo) OR (description LIKE 'RESERVATION:admin:walkin:%' AND created_at < $2)
OR (description LIKE 'RESERVATION:admin:callin:%' AND created_at < $3)
`, oneHourAgo, tenMinutesAgo, oneHourAgo)
return err
}
// AnonymizeStaleGuestAccounts anonymizes personal data for guest accounts
// whose last booking was more than 6 months ago (UK GDPR storage limitation).
// Financial records (bookings, payments) remain intact — only PII is scrubbed.
// Active/pending bookings are excluded so the salon can still contact the guest.
func AnonymizeStaleGuestAccounts(ctx context.Context) error {
_, err := db.DB.Exec(ctx, `
UPDATE users SET
n_first_name = 'Guest',
n_last_name = 'Anonymized',
email = 'anon-' || id || '@anon.invalid',
phone = '000000000000',
date_of_birth = '1900-01-01',
updated_at = NOW()
WHERE account_role = 'guest'
AND id NOT IN (
SELECT user_id FROM bookings WHERE status IN ('pending', 'confirmed')
)
AND id IN (
SELECT user_id
FROM bookings
WHERE user_id IS NOT NULL
GROUP BY user_id
HAVING MAX(start_time) < NOW() - INTERVAL '6 months'
)
`)
return err return err
} }