Files
Crussell/backend/handlers/user/account.go
T
popertotsandSisyphus 40840c0458 feat(user): extend DeleteAccountHandler with external system scrubbing
DeleteAccountHandler now scrubs external systems BEFORE SQL-level anonymization. S3: deletes profile picture from S3/R2 (profiles/{userID}.jpg) when s3.Client is configured. Square: iterates user's saved cards and calls DeleteCardOnFile for each non-deleted card when payments.SquareClient is configured. Both run in background goroutines (non-blocking, best-effort with warning logs). CardDAV contact deletion remains as existing best-effort goroutine.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-05 12:12:12 +01:00

112 lines
3.1 KiB
Go

package user
import (
"context"
"database/sql"
"fmt"
"log"
"net/http"
"os"
"crussell/db"
"crussell/handlers/payments"
"crussell/internal/dav"
"crussell/internal/s3"
"crussell/mw"
)
// DELETE /api/user/account
func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
userID, ok := mw.GetUserID(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var accountRole string
var profilePicURL sql.NullString
err := db.DB.QueryRow(r.Context(), `SELECT account_role, profile_pic_url FROM users WHERE id = $1`, userID).
Scan(&accountRole, &profilePicURL)
if err != nil {
if err == sql.ErrNoRows {
http.Error(w, "user not found", http.StatusNotFound)
return
}
log.Printf("Failed to fetch user for deletion: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
ctx := r.Context()
// --- External system scrubbing (BEFORE SQL anonymize) ---
// Delete profile picture from S3/R2
if profilePicURL.Valid && profilePicURL.String != "" && s3.Client != nil {
go func(picURL string) {
bucket := os.Getenv("S3_PROFILE_PICS_BUCKET")
if bucket == "" {
bucket = "crussell-profile-pics"
}
// profiles/{userID}.jpg — matches UploadProfilePictureHandler key format
key := fmt.Sprintf("profiles/%s.jpg", userID)
if err := s3.Client.Delete(context.Background(), bucket, key); err != nil {
log.Printf("Warning: Failed to delete profile picture for user %s: %v", userID, err)
}
}(profilePicURL.String)
}
if payments.SquareClient != nil {
go func() {
rows, err := db.DB.Query(ctx,
`SELECT square_card_id FROM user_saved_cards WHERE user_id = $1 AND deleted_at IS NULL`, userID)
if err != nil {
log.Printf("Warning: Failed to query saved cards for user %s: %v", userID, err)
return
}
defer rows.Close()
for rows.Next() {
var cardID string
if err := rows.Scan(&cardID); err != nil {
log.Printf("Warning: Failed to scan card ID for user %s: %v", userID, err)
continue
}
if err := payments.SquareClient.DeleteCardOnFile(context.Background(), cardID); err != nil {
log.Printf("Warning: Failed to delete Square card %s for user %s: %v", cardID, userID, err)
}
}
}()
}
// --- SQL-level anonymization/deletion ---
if accountRole == "guest" {
_, err = db.DB.Exec(ctx, `SELECT delete_guest_user($1)`, userID)
if err != nil {
log.Printf("Failed to delete guest user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
} else {
_, err = db.DB.Exec(ctx, `SELECT anonymize_user($1)`, userID)
if err != nil {
log.Printf("Failed to anonymize user %s: %v", userID, err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
// TODO: Create 'user_anonymized' notification for admin audit trail
}
// Delete CardDAV contact (non-blocking, best-effort)
if dav.Service != nil {
go func() {
uri := fmt.Sprintf("%s.vcf", userID)
if err := dav.Service.DeleteContact(1, uri); err != nil {
log.Printf("Warning: Failed to delete CardDAV contact for user %s: %v", userID, err)
}
}()
}
w.WriteHeader(http.StatusNoContent)
}