feat: 10 quick wins — account deletion, health check, debug cleanup, UX polish, graceful shutdown

- backend/handlers/user/account.go: Wire DELETE /api/user/account to call
  anonymize_user() for registered users and delete_guest_user() for guests,
  with CardDAV contact cleanup
- backend/handlers/user/profile_test.go: Add TestAccount_DeleteGuest and
  enhance TestAccount_Delete to verify anonymization results
- backend/main.go: Add GET /api/health endpoint with DB ping and S3 status
  check; add HSTS and Referrer-Policy security headers; replace
  http.ListenAndServe with http.Server + graceful SIGTERM/SIGINT shutdown
- frontend/routes/+layout.svelte: Replace alert() with toast notifications
  for email verification flow
- frontend/routes/login/+page.svelte: Replace alert() with toast.info for
  social login prototype buttons
- frontend/booking/BookingFlow.svelte: Remove 2 console.log debug calls;
  add cancellation policy note in Step 3; add timezone policy comment
- frontend/ImageUpload.svelte: Comment out debug console.log
- init-scripts/init-script.sql: Add delete_guest_user() SQL function
- docs: Update README.md and Obsidian notes to reflect completed items
This commit is contained in:
2026-05-01 11:33:27 +01:00
parent 7b396b7a9d
commit bff86a6660
11 changed files with 673 additions and 435 deletions
+50 -1
View File
@@ -1,12 +1,61 @@
package user
import (
"database/sql"
"fmt"
"log"
"net/http"
"crussell/db"
"crussell/internal/dav"
"crussell/mw"
)
// DELETE /api/user/account
func DeleteAccountHandler(w http.ResponseWriter, r *http.Request) {
// TODO: Delete user's data
userID, ok := mw.GetUserID(r.Context())
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var accountRole string
err := db.DB.QueryRow(r.Context(), `SELECT account_role FROM users WHERE id = $1`, userID).Scan(&accountRole)
if err != nil {
if err == sql.ErrNoRows {
http.Error(w, "user not found", http.StatusNotFound)
return
}
log.Printf("Failed to fetch user role for deletion: %v", err)
http.Error(w, "server error", http.StatusInternalServerError)
return
}
if accountRole == "guest" {
_, err = db.DB.Exec(r.Context(), `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(r.Context(), `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
}
}
// 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)
}
+48 -1
View File
@@ -239,7 +239,8 @@ func TestPasswordChange_InvalidNewPassword(t *testing.T) {
}
}
// TestAccount_Delete verifies that a user can delete their own account, returning 204 No Content.
// TestAccount_Delete verifies that a registered user can delete their own account,
// triggering anonymization and returning 204 No Content.
func TestAccount_Delete(t *testing.T) {
cleanup, pool := setupTest(t)
defer cleanup()
@@ -262,6 +263,52 @@ func TestAccount_Delete(t *testing.T) {
t.Errorf("expected status 204, got %d", rr.Code)
t.Logf("response body: %s", rr.Body.String())
}
var firstName, accountRole string
err = pool.QueryRow(context.Background(), `SELECT n_first_name, account_role FROM users WHERE id = $1`, userID).Scan(&firstName, &accountRole)
if err != nil {
t.Fatalf("failed to query anonymized user: %v", err)
}
if firstName != "Deleted" {
t.Errorf("expected first name 'Deleted', got %q", firstName)
}
if accountRole != "guest" {
t.Errorf("expected account_role 'guest', got %q", accountRole)
}
}
// TestAccount_DeleteGuest verifies that a guest user is fully deleted.
func TestAccount_DeleteGuest(t *testing.T) {
cleanup, pool := setupTest(t)
defer cleanup()
userID, err := fixtures.CreateTestGuestUser(pool)
if err != nil {
t.Fatalf("failed to create test guest user: %v", err)
}
token := jwt.GenerateUserToken(userID)
req := httptest.NewRequest(http.MethodDelete, "/api/user/account", nil)
req = req.WithContext(context.WithValue(context.Background(), mw.UserIDKey, userID))
req.Header.Set("Authorization", "Bearer "+token)
rr := httptest.NewRecorder()
DeleteAccountHandler(rr, req)
if rr.Code != http.StatusNoContent {
t.Errorf("expected status 204, got %d", rr.Code)
t.Logf("response body: %s", rr.Body.String())
}
var count int
err = pool.QueryRow(context.Background(), `SELECT COUNT(*) FROM users WHERE id = $1`, userID).Scan(&count)
if err != nil {
t.Fatalf("failed to query user count: %v", err)
}
if count != 0 {
t.Errorf("expected user to be deleted, found %d rows", count)
}
}
// TestLoyalty_Get verifies that a user can retrieve their loyalty stamps count and referral code.