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.
+68 -1
View File
@@ -1,13 +1,17 @@
package main
import (
"context"
"crussell/auth"
"crussell/internal/dav"
"crussell/internal/s3"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/go-chi/chi/v5"
@@ -56,6 +60,42 @@ func initS3() {
}
}
func healthCheckHandler(w http.ResponseWriter, r *http.Request) {
status := "ok"
services := map[string]string{
"backend": "ok",
"database": "ok",
"s3_storage": "ok",
"square_payments": "not_implemented",
"frontend": "unknown",
}
if db.DB != nil {
if err := db.DB.Ping(r.Context()); err != nil {
services["database"] = "error"
status = "degraded"
}
} else {
services["database"] = "error"
status = "degraded"
}
if s3.Client == nil {
services["s3_storage"] = "not_configured"
}
w.Header().Set("Content-Type", "application/json")
if status == "degraded" {
w.WriteHeader(http.StatusServiceUnavailable)
} else {
w.WriteHeader(http.StatusOK)
}
json.NewEncoder(w).Encode(map[string]interface{}{
"status": status,
"services": services,
})
}
func main() {
initDB()
initDav()
@@ -74,6 +114,10 @@ func main() {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "1; mode=block")
// TODO: Enable HSTS in production
w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
// TODO: Enable Referrer-Policy in production
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
next.ServeHTTP(w, r)
})
})
@@ -98,6 +142,9 @@ func main() {
r.Post("/verify/generate", authHandlers.GenerateVerificationCodeHandler)
r.Post("/verify/check", authHandlers.VerifyCodeHandler)
// Health check
r.Get("/health", healthCheckHandler)
// Public contact info
r.Get("/contact", user.GetContactInfoHandler)
@@ -225,6 +272,26 @@ func main() {
})
})
srv := &http.Server{
Addr: ":8080",
Handler: r,
}
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT)
go func() {
<-quit
log.Println("Shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("Server forced to shutdown: %v", err)
}
}()
fmt.Println("Server is listening on :8080")
http.ListenAndServe(":8080", r)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server failed to start: %v", err)
}
log.Println("Server exited")
}