Files
Crussell/backend/handlers/user/account.go
T
popertotsandSisyphus 3d0e2afc4c refactor(backend): migrate db.DB to db.Conn PoolProxy across all handlers
Replace direct *pgxpool.Pool usage with PoolProxy wrapper across the entire backend:

- db.DB renamed to db.Conn (*pgxpool.Pool -> *PoolProxy)
- JWT functions now accept context.Context instead of using context.Background()
- Handler DB calls route through PoolProxy for per-test transaction support
- Fixture/helper/testdb functions accept Querier interface for decoupling
- Query ordering fixed in bookings handlers: COUNT after data query to avoid pgx conn busy
- Time truncation fixed: time.Date instead of Truncate(24*time.Hour) for week start calc
- testmain_test.go files updated with SeedBaseline and NewPoolProxy

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-21 19:28:54 +01:00

114 lines
3.2 KiB
Go

package user
import (
"context"
"database/sql"
"errors"
"fmt"
"log"
"net/http"
"os"
"crussell/db"
"crussell/handlers/payments"
"crussell/internal/dav"
"crussell/internal/s3"
"crussell/mw"
"github.com/jackc/pgx/v5"
)
// 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.Conn.QueryRow(r.Context(), `SELECT account_role, profile_pic_url FROM users WHERE id = $1`, userID).
Scan(&accountRole, &profilePicURL)
if err != nil {
if errors.Is(err, pgx.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.Conn.Query(context.Background(),
`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.Conn.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.Conn.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)
}