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.
|
||||
|
||||
+68
-1
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user