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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user