From 2db260f849f22cd94eeb9796311ca6543e5bd1af Mon Sep 17 00:00:00 2001 From: juancwu Date: Sun, 17 May 2026 15:01:04 +0000 Subject: [PATCH] feat: account deletion --- cmd/server/main.go | 1 + internal/app/app.go | 8 +- ...create_account_deletion_requests_table.sql | 45 ++++ internal/handler/settings.go | 54 ++++- internal/handler/settings_test.go | 2 +- internal/middleware/csrf.go | 2 +- internal/middleware/logging.go | 2 +- internal/middleware/pending_deletion.go | 57 +++++ internal/middleware/ratelimit.go | 6 +- internal/model/account_deletion_request.go | 30 +++ internal/model/identity_access.go | 15 +- .../repository/account_deletion_request.go | 132 ++++++++++++ internal/repository/user.go | 32 +++ internal/routes/routes.go | 6 + internal/routes/routes_test.go | 2 +- internal/service/user.go | 199 +++++++++++++++++- internal/service/user_test.go | 4 +- .../ui/pages/account_pending_deletion.templ | 52 +++++ internal/ui/pages/app_settings.templ | 108 +++++++++- internal/worker/account_deletion.go | 57 +++++ 20 files changed, 785 insertions(+), 29 deletions(-) create mode 100644 internal/db/migrations/00018_create_account_deletion_requests_table.sql create mode 100644 internal/middleware/pending_deletion.go create mode 100644 internal/model/account_deletion_request.go create mode 100644 internal/repository/account_deletion_request.go create mode 100644 internal/ui/pages/account_pending_deletion.templ create mode 100644 internal/worker/account_deletion.go diff --git a/cmd/server/main.go b/cmd/server/main.go index e45b01d..d3e38f0 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -58,6 +58,7 @@ func main() { workerCtx, stopWorker := context.WithCancel(context.Background()) defer stopWorker() go runRecurringWorker(workerCtx, a) + go a.AccountDeletionWorker.Start(workerCtx) go func() { sigCh := make(chan os.Signal, 1) diff --git a/internal/app/app.go b/internal/app/app.go index 062a8d4..22fca04 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -2,11 +2,13 @@ package app import ( "fmt" + "time" "git.juancwu.dev/juancwu/budgit/internal/config" "git.juancwu.dev/juancwu/budgit/internal/db" "git.juancwu.dev/juancwu/budgit/internal/repository" "git.juancwu.dev/juancwu/budgit/internal/service" + "git.juancwu.dev/juancwu/budgit/internal/worker" "github.com/jmoiron/sqlx" ) @@ -25,6 +27,7 @@ type App struct { AuditLogService *service.SpaceAuditLogService TxAuditLogService *service.TransactionAuditLogService AccountActivitySvc *service.AccountActivityService + AccountDeletionWorker *worker.AccountDeletionWorker } func New(cfg *config.Config) (*App, error) { @@ -52,9 +55,11 @@ func New(cfg *config.Config) (*App, error) { auditLogRepository := repository.NewSpaceAuditLogRepository(database) txAuditLogRepository := repository.NewTransactionAuditLogRepository(database) recurringEventRepository := repository.NewRecurringEventRepository(database) + accountDeletionRequestRepo := repository.NewAccountDeletionRequestRepository(database) // Services - userService := service.NewUserService(userRepository) + userService := service.NewUserService(database, userRepository, accountDeletionRequestRepo) + accountDeletionWorker := worker.NewAccountDeletionWorker(userService, 30*time.Second) auditLogService := service.NewSpaceAuditLogService(auditLogRepository) txAuditLogService := service.NewTransactionAuditLogService(txAuditLogRepository) spaceService := service.NewSpaceService(spaceRepository) @@ -105,6 +110,7 @@ func New(cfg *config.Config) (*App, error) { AuditLogService: auditLogService, TxAuditLogService: txAuditLogService, AccountActivitySvc: accountActivityService, + AccountDeletionWorker: accountDeletionWorker, }, nil } diff --git a/internal/db/migrations/00018_create_account_deletion_requests_table.sql b/internal/db/migrations/00018_create_account_deletion_requests_table.sql new file mode 100644 index 0000000..863e009 --- /dev/null +++ b/internal/db/migrations/00018_create_account_deletion_requests_table.sql @@ -0,0 +1,45 @@ +-- +goose Up +-- +goose StatementBegin +-- Flag on users for fast middleware lookups: once set, every request from the +-- user is funneled to the "Account Pending Deletion" page until the background +-- worker finishes wiping their data. +ALTER TABLE users ADD COLUMN pending_deletion_at TIMESTAMP NULL; +CREATE INDEX idx_users_pending_deletion_at ON users (pending_deletion_at) WHERE pending_deletion_at IS NOT NULL; + +-- Single table that acts as both the work queue AND the permanent audit +-- record for account deletion requests. Rows are not foreign-keyed to users +-- because the related user row is hard-deleted on completion; the snapshot +-- columns preserve who/when/from-where for audit purposes after the data is +-- gone. Operational columns (status, attempts, last_error) let a background +-- worker pick the row up, retry on failure, and resume across restarts. +CREATE TABLE account_deletion_requests ( + id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL, + email TEXT NOT NULL, + name TEXT NULL, + reason TEXT NULL, + ip_address TEXT NULL, + status TEXT NOT NULL DEFAULT 'pending', -- pending | processing | completed | failed + attempts INTEGER NOT NULL DEFAULT 0, + last_error TEXT NULL, + spaces_deleted INTEGER NULL, + requested_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMP NULL +); + +CREATE INDEX idx_account_deletion_requests_pending + ON account_deletion_requests (requested_at) + WHERE status IN ('pending', 'processing'); +CREATE INDEX idx_account_deletion_requests_user_id + ON account_deletion_requests (user_id); +CREATE INDEX idx_account_deletion_requests_requested_at + ON account_deletion_requests (requested_at DESC); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE account_deletion_requests; +DROP INDEX IF EXISTS idx_users_pending_deletion_at; +ALTER TABLE users DROP COLUMN pending_deletion_at; +-- +goose StatementEnd diff --git a/internal/handler/settings.go b/internal/handler/settings.go index bf80a63..05c0f43 100644 --- a/internal/handler/settings.go +++ b/internal/handler/settings.go @@ -6,6 +6,7 @@ import ( "net/http" "git.juancwu.dev/juancwu/budgit/internal/ctxkeys" + "git.juancwu.dev/juancwu/budgit/internal/middleware" "git.juancwu.dev/juancwu/budgit/internal/service" "git.juancwu.dev/juancwu/budgit/internal/ui" "git.juancwu.dev/juancwu/budgit/internal/ui/components/toast" @@ -35,7 +36,54 @@ func (h *settingsHandler) SettingsPage(w http.ResponseWriter, r *http.Request) { return } - ui.Render(w, r, pages.AppSettings(fullUser.HasPassword(), "")) + ui.Render(w, r, pages.AppSettings(fullUser.HasPassword(), fullUser.Email, "", "")) +} + +func (h *settingsHandler) DeleteAccount(w http.ResponseWriter, r *http.Request) { + user := ctxkeys.User(r.Context()) + + fullUser, err := h.userService.ByID(user.ID) + if err != nil { + slog.Error("failed to fetch user for account deletion", "error", err, "user_id", user.ID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + confirmation := r.FormValue("confirm_email") + reason := r.FormValue("reason") + + err = h.userService.RequestAccountDeletion(service.RequestAccountDeletionInput{ + UserID: user.ID, + ConfirmationEmail: confirmation, + Reason: reason, + IPAddress: middleware.GetClientIP(r), + }) + if err != nil { + slog.Warn("account deletion request failed", "error", err, "user_id", user.ID) + + msg := "We couldn't queue your account for deletion. Please try again." + if errors.Is(err, service.ErrEmailConfirmationMismatch) { + msg = "The email you entered does not match your account email." + } else if errors.Is(err, service.ErrAccountAlreadyPending) { + // Race with another tab — just send them to the pending page. + http.Redirect(w, r, "/account-pending-deletion", http.StatusSeeOther) + return + } + ui.Render(w, r, pages.AppSettings(fullUser.HasPassword(), fullUser.Email, "", msg)) + return + } + + slog.Info("account deletion queued", "user_id", user.ID, "email", fullUser.Email) + http.Redirect(w, r, "/account-pending-deletion", http.StatusSeeOther) +} + +func (h *settingsHandler) AccountPendingDeletionPage(w http.ResponseWriter, r *http.Request) { + user := ctxkeys.User(r.Context()) + if user == nil || !user.IsPendingDeletion() { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + ui.Render(w, r, pages.AccountPendingDeletion(*user.PendingDeletionAt)) } func (h *settingsHandler) SetPassword(w http.ResponseWriter, r *http.Request) { @@ -66,12 +114,12 @@ func (h *settingsHandler) SetPassword(w http.ResponseWriter, r *http.Request) { msg = "Password must be at least 12 characters" } - ui.Render(w, r, pages.AppSettings(fullUser.HasPassword(), msg)) + ui.Render(w, r, pages.AppSettings(fullUser.HasPassword(), fullUser.Email, msg, "")) return } // Password set successfully — render page with success toast - ui.Render(w, r, pages.AppSettings(true, "")) + ui.Render(w, r, pages.AppSettings(true, fullUser.Email, "", "")) ui.RenderToast(w, r, toast.Toast(toast.Props{ Title: "Password updated", Variant: toast.VariantSuccess, diff --git a/internal/handler/settings_test.go b/internal/handler/settings_test.go index 41c230b..667bc95 100644 --- a/internal/handler/settings_test.go +++ b/internal/handler/settings_test.go @@ -22,7 +22,7 @@ func newTestSettingsHandler(dbi testutil.DBInfo) (*settingsHandler, *service.Aut accountSvc := service.NewAccountService(accountRepo) emailSvc := service.NewEmailService(nil, "test@example.com", "http://localhost:9999", "Budgit Test", false) authSvc := service.NewAuthService(emailSvc, userRepo, tokenRepo, spaceSvc, accountSvc, cfg.JWTSecret, cfg.JWTExpiry, cfg.TokenMagicLinkExpiry, false, false) - userSvc := service.NewUserService(userRepo) + userSvc := service.NewUserService(dbi.DB, userRepo, repository.NewAccountDeletionRequestRepository(dbi.DB)) return NewSettingsHandler(authSvc, userSvc), authSvc } diff --git a/internal/middleware/csrf.go b/internal/middleware/csrf.go index d23f088..6634288 100644 --- a/internal/middleware/csrf.go +++ b/internal/middleware/csrf.go @@ -64,7 +64,7 @@ func CSRFProtection(next http.Handler) http.HandlerFunc { slog.Warn("csrf validation failed", "path", r.URL.Path, "method", r.Method, - "ip", getClientIP(r), + "ip", GetClientIP(r), ) http.Error(w, "Invalid CSRF token", http.StatusForbidden) return diff --git a/internal/middleware/logging.go b/internal/middleware/logging.go index 14ed726..3ea89c8 100644 --- a/internal/middleware/logging.go +++ b/internal/middleware/logging.go @@ -64,7 +64,7 @@ func RequestLogging(next http.Handler) http.HandlerFunc { "path", r.URL.Path, "status", rw.statusCode, "duration_ms", duration.Milliseconds(), - "remote_addr", getClientIP(r), + "remote_addr", GetClientIP(r), ) }) } diff --git a/internal/middleware/pending_deletion.go b/internal/middleware/pending_deletion.go new file mode 100644 index 0000000..248c8e0 --- /dev/null +++ b/internal/middleware/pending_deletion.go @@ -0,0 +1,57 @@ +package middleware + +import ( + "net/http" + "strings" + + "git.juancwu.dev/juancwu/budgit/internal/ctxkeys" + "git.juancwu.dev/juancwu/budgit/internal/ui" + "git.juancwu.dev/juancwu/budgit/internal/ui/pages" +) + +// pendingDeletionAllowedPaths is the small set of endpoints a user marked +// for deletion is still allowed to reach. Everything else is redirected (GET) +// or rejected (mutation) so no further data can be created or changed while +// the deletion job is in flight. +var pendingDeletionAllowedPaths = map[string]struct{}{ + "/account-pending-deletion": {}, + "/auth/logout": {}, + "/healthz": {}, + "/privacy": {}, + "/terms": {}, + "/forbidden": {}, +} + +// BlockPendingDeletion locks out users whose accounts are pending deletion. +// Runs after AuthMiddleware so it can read the user from context. For +// unauthenticated requests and static assets it is a no-op. +func BlockPendingDeletion(next http.Handler) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + user := ctxkeys.User(r.Context()) + if user == nil || !user.IsPendingDeletion() { + next.ServeHTTP(w, r) + return + } + + // Always permit static assets so the pending page can render. + if strings.HasPrefix(r.URL.Path, "/assets/") { + next.ServeHTTP(w, r) + return + } + + if _, ok := pendingDeletionAllowedPaths[r.URL.Path]; ok { + next.ServeHTTP(w, r) + return + } + + // Mutations are hard-rejected so the client gets a clear signal. + // Safe methods are redirected to the pending-deletion landing page. + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.WriteHeader(http.StatusForbidden) + ui.Render(w, r, pages.AccountPendingDeletion(*user.PendingDeletionAt)) + return + } + + ui.Render(w, r, pages.AccountPendingDeletion(*user.PendingDeletionAt)) + } +} diff --git a/internal/middleware/ratelimit.go b/internal/middleware/ratelimit.go index 5a39331..df93cd4 100644 --- a/internal/middleware/ratelimit.go +++ b/internal/middleware/ratelimit.go @@ -101,7 +101,7 @@ func (rl *RateLimiter) cleanup() { func (rl *RateLimiter) Middleware() Middleware { return func(next http.Handler) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - ip := getClientIP(r) + ip := GetClientIP(r) if !rl.Allow(ip) { slog.Warn("rate limit exceeded", "ip", ip, @@ -115,8 +115,8 @@ func (rl *RateLimiter) Middleware() Middleware { } } -// getClientIP extracts real client IP from request -func getClientIP(r *http.Request) string { +// GetClientIP extracts real client IP from request +func GetClientIP(r *http.Request) string { // Check X-Forwarded-For header (proxy/load balancer) xff := r.Header.Get("X-Forwarded-For") if xff != "" { diff --git a/internal/model/account_deletion_request.go b/internal/model/account_deletion_request.go new file mode 100644 index 0000000..f1d4337 --- /dev/null +++ b/internal/model/account_deletion_request.go @@ -0,0 +1,30 @@ +package model + +import "time" + +const ( + AccountDeletionStatusPending = "pending" + AccountDeletionStatusProcessing = "processing" + AccountDeletionStatusCompleted = "completed" + AccountDeletionStatusFailed = "failed" +) + +// AccountDeletionRequest is both the work queue entry and the historical +// audit record for an account deletion. The row is created when the user +// confirms deletion, transitions through processing, and is kept after +// completion as the audit trail (the related user row is gone by then). +type AccountDeletionRequest struct { + ID string `db:"id"` + UserID string `db:"user_id"` + Email string `db:"email"` + Name *string `db:"name"` + Reason *string `db:"reason"` + IPAddress *string `db:"ip_address"` + Status string `db:"status"` + Attempts int `db:"attempts"` + LastError *string `db:"last_error"` + SpacesDeleted *int `db:"spaces_deleted"` + RequestedAt time.Time `db:"requested_at"` + UpdatedAt time.Time `db:"updated_at"` + CompletedAt *time.Time `db:"completed_at"` +} diff --git a/internal/model/identity_access.go b/internal/model/identity_access.go index c61f9d9..b149fc2 100644 --- a/internal/model/identity_access.go +++ b/internal/model/identity_access.go @@ -7,11 +7,16 @@ type User struct { Email string `db:"email"` Name *string `db:"name"` // Allow null for passwordless users - PasswordHash *string `db:"password_hash"` - PendingEmail *string `db:"pending_email"` - EmailVerifiedAt *time.Time `db:"email_verified_at"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` + PasswordHash *string `db:"password_hash"` + PendingEmail *string `db:"pending_email"` + EmailVerifiedAt *time.Time `db:"email_verified_at"` + PendingDeletionAt *time.Time `db:"pending_deletion_at"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` +} + +func (u *User) IsPendingDeletion() bool { + return u.PendingDeletionAt != nil } func (u *User) HasPassword() bool { diff --git a/internal/repository/account_deletion_request.go b/internal/repository/account_deletion_request.go new file mode 100644 index 0000000..4ce8d7f --- /dev/null +++ b/internal/repository/account_deletion_request.go @@ -0,0 +1,132 @@ +package repository + +import ( + "database/sql" + "errors" + "time" + + "git.juancwu.dev/juancwu/budgit/internal/model" + "github.com/jmoiron/sqlx" +) + +var ErrAccountDeletionRequestNotFound = errors.New("account deletion request not found") + +type AccountDeletionRequestRepository interface { + CreateTx(tx *sqlx.Tx, req *model.AccountDeletionRequest) error + HasPendingForUser(userID string) (bool, error) + + // ClaimNextPending atomically transitions the oldest pending request to + // "processing" and returns it. Returns ErrAccountDeletionRequestNotFound + // when no pending row exists. Uses SKIP LOCKED so multiple workers can + // safely run in parallel without colliding. + ClaimNextPending() (*model.AccountDeletionRequest, error) + + // MarkCompletedTx marks the request as completed within the same tx that + // deletes the user's data, so the queue row and the data wipe always + // agree. + MarkCompletedTx(tx *sqlx.Tx, id string, spacesDeleted int) error + + // MarkFailedRetryable records the error and returns the request to the + // pending state so the next worker tick will retry it. + MarkFailedRetryable(id string, errMsg string) error + + // MarkFailedTerminal records the error and parks the request in the + // failed state for human investigation. Called once attempts exceed the + // retry budget. + MarkFailedTerminal(id string, errMsg string) error +} + +type accountDeletionRequestRepository struct { + db *sqlx.DB +} + +func NewAccountDeletionRequestRepository(db *sqlx.DB) AccountDeletionRequestRepository { + return &accountDeletionRequestRepository{db: db} +} + +func (r *accountDeletionRequestRepository) CreateTx(tx *sqlx.Tx, req *model.AccountDeletionRequest) error { + _, err := tx.Exec( + `INSERT INTO account_deletion_requests + (id, user_id, email, name, reason, ip_address, status, attempts, + requested_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9);`, + req.ID, req.UserID, req.Email, req.Name, req.Reason, req.IPAddress, + req.Status, req.Attempts, req.RequestedAt, + ) + return err +} + +func (r *accountDeletionRequestRepository) HasPendingForUser(userID string) (bool, error) { + var n int + err := r.db.Get(&n, + `SELECT COUNT(*) FROM account_deletion_requests + WHERE user_id = $1 AND status IN ('pending', 'processing');`, + userID, + ) + return n > 0, err +} + +func (r *accountDeletionRequestRepository) ClaimNextPending() (*model.AccountDeletionRequest, error) { + var req model.AccountDeletionRequest + err := r.db.Get(&req, + `UPDATE account_deletion_requests + SET status = 'processing', + attempts = attempts + 1, + updated_at = $1 + WHERE id = ( + SELECT id FROM account_deletion_requests + WHERE status = 'pending' + ORDER BY requested_at + FOR UPDATE SKIP LOCKED + LIMIT 1 + ) + RETURNING *;`, + time.Now(), + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrAccountDeletionRequestNotFound + } + if err != nil { + return nil, err + } + return &req, nil +} + +func (r *accountDeletionRequestRepository) MarkCompletedTx(tx *sqlx.Tx, id string, spacesDeleted int) error { + now := time.Now() + _, err := tx.Exec( + `UPDATE account_deletion_requests + SET status = 'completed', + spaces_deleted = $2, + completed_at = $3, + updated_at = $3, + last_error = NULL + WHERE id = $1;`, + id, spacesDeleted, now, + ) + return err +} + +func (r *accountDeletionRequestRepository) MarkFailedRetryable(id string, errMsg string) error { + _, err := r.db.Exec( + `UPDATE account_deletion_requests + SET status = 'pending', + last_error = $2, + updated_at = $3 + WHERE id = $1;`, + id, errMsg, time.Now(), + ) + return err +} + +func (r *accountDeletionRequestRepository) MarkFailedTerminal(id string, errMsg string) error { + _, err := r.db.Exec( + `UPDATE account_deletion_requests + SET status = 'failed', + last_error = $2, + updated_at = $3 + WHERE id = $1;`, + id, errMsg, time.Now(), + ) + return err +} diff --git a/internal/repository/user.go b/internal/repository/user.go index 7b89cdb..49e14d7 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -4,6 +4,7 @@ import ( "database/sql" "errors" "strings" + "time" "git.juancwu.dev/juancwu/budgit/internal/model" "github.com/jmoiron/sqlx" @@ -20,6 +21,11 @@ type UserRepository interface { ByEmail(email string) (*model.User, error) Update(user *model.User) error Delete(id string) error + + // MarkPendingDeletionTx sets the pending_deletion_at flag inside the + // supplied transaction. The flag is what middleware checks on every + // request to lock the user out of any further actions. + MarkPendingDeletionTx(tx *sqlx.Tx, userID string, at time.Time) error } type userRepository struct { @@ -77,6 +83,32 @@ func (r *userRepository) Update(user *model.User) error { return err } +func (r *userRepository) MarkPendingDeletionTx(tx *sqlx.Tx, userID string, at time.Time) error { + res, err := tx.Exec( + `UPDATE users SET pending_deletion_at = $1, updated_at = $1 WHERE id = $2 AND pending_deletion_at IS NULL;`, + at, userID, + ) + if err != nil { + return err + } + rows, err := res.RowsAffected() + if err != nil { + return err + } + if rows == 0 { + // Either the user does not exist or is already pending. Verify so we + // can distinguish. + var exists bool + if err := tx.Get(&exists, `SELECT EXISTS(SELECT 1 FROM users WHERE id = $1);`, userID); err != nil { + return err + } + if !exists { + return ErrUserNotFound + } + } + return nil +} + func (r *userRepository) Delete(id string) error { query := `DELETE FROM users WHERE id = $1;` diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 2beaad6..d08c19d 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -34,6 +34,7 @@ func SetupRoutes(a *app.App) http.Handler { middleware.NoCacheDynamic, middleware.CSRFProtection, middleware.AuthMiddleware(a.AuthService, a.UserService), + middleware.BlockPendingDeletion, middleware.WithURLPath, middleware.WithSidebarState, ) @@ -83,6 +84,10 @@ func SetupRoutes(a *app.App) http.Handler { }) r.Post("/auth/logout", authH.Logout).Name("action.auth.logout") + // Account pending deletion page — reachable while the deletion worker + // finishes wiping the user's data. + r.Get("/account-pending-deletion", settingsH.AccountPendingDeletionPage).Name("page.account.pending-deletion") + // App routes r.Group("/app", func(g *router.Group) { g.Use(middleware.RequireAuth) @@ -154,6 +159,7 @@ func SetupRoutes(a *app.App) http.Handler { g.SubGroup("", func(g *router.Group) { g.RateLimit(5, 15*time.Minute) g.Post("/password", settingsH.SetPassword).Name("action.app.settings.password.set") + g.Post("/delete-account", settingsH.DeleteAccount).Name("action.app.settings.account.delete") }) }) }) diff --git a/internal/routes/routes_test.go b/internal/routes/routes_test.go index a2fb564..b277227 100644 --- a/internal/routes/routes_test.go +++ b/internal/routes/routes_test.go @@ -27,7 +27,7 @@ func newTestApp(dbi testutil.DBInfo) *app.App { accountSvc := service.NewAccountService(accountRepo) emailSvc := service.NewEmailService(nil, "test@example.com", "http://localhost:9999", "Budgit Test", false) authSvc := service.NewAuthService(emailSvc, userRepo, tokenRepo, spaceSvc, accountSvc, cfg.JWTSecret, cfg.JWTExpiry, cfg.TokenMagicLinkExpiry, false, false) - userSvc := service.NewUserService(userRepo) + userSvc := service.NewUserService(dbi.DB, userRepo, repository.NewAccountDeletionRequestRepository(dbi.DB)) inviteSvc := service.NewInviteService(inviteRepo, spaceRepo, userRepo, emailSvc, nil) return &app.App{ diff --git a/internal/service/user.go b/internal/service/user.go index 6b60637..fa478be 100644 --- a/internal/service/user.go +++ b/internal/service/user.go @@ -1,25 +1,216 @@ package service import ( + "errors" + "fmt" + "log/slog" + "strings" + "time" + "git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/repository" + "github.com/google/uuid" + "github.com/jmoiron/sqlx" ) +var ( + ErrEmailConfirmationMismatch = errors.New("email confirmation does not match") + ErrAccountAlreadyPending = errors.New("account deletion already pending") +) + +// maxAccountDeletionAttempts caps how many times a single deletion request +// gets retried before being parked in the failed state for human attention. +const maxAccountDeletionAttempts = 5 + type UserService struct { - userRepository repository.UserRepository + db *sqlx.DB + userRepository repository.UserRepository + deletionRequestRepo repository.AccountDeletionRequestRepository + // triggerDeletion is set by the worker so that handlers can wake the + // worker up immediately after enqueueing a new request, instead of + // waiting for the next periodic tick. + triggerDeletion chan<- struct{} } -func NewUserService(userRepository repository.UserRepository) *UserService { +func NewUserService( + db *sqlx.DB, + userRepository repository.UserRepository, + deletionRequestRepo repository.AccountDeletionRequestRepository, +) *UserService { return &UserService{ - userRepository: userRepository, + db: db, + userRepository: userRepository, + deletionRequestRepo: deletionRequestRepo, } } +func (s *UserService) SetDeletionTrigger(ch chan<- struct{}) { + s.triggerDeletion = ch +} + func (s *UserService) ByID(id string) (*model.User, error) { user, err := s.userRepository.ByID(id) if err != nil { return nil, err } - return user, nil } + +// RequestAccountDeletionInput captures the user's confirmed intent to +// permanently delete their account. +type RequestAccountDeletionInput struct { + UserID string + ConfirmationEmail string + Reason string + IPAddress string +} + +// RequestAccountDeletion validates the user's intent, flags the account as +// pending deletion (so middleware can lock out further activity), and +// enqueues a deletion job for the background worker to pick up. Both +// operations happen in a single transaction so we never end up with a +// flagged user without a queue entry, or vice versa. +func (s *UserService) RequestAccountDeletion(input RequestAccountDeletionInput) error { + user, err := s.userRepository.ByID(input.UserID) + if err != nil { + return fmt.Errorf("lookup user: %w", err) + } + + if !strings.EqualFold(strings.TrimSpace(input.ConfirmationEmail), user.Email) { + return ErrEmailConfirmationMismatch + } + + if user.IsPendingDeletion() { + return ErrAccountAlreadyPending + } + + now := time.Now() + req := &model.AccountDeletionRequest{ + ID: uuid.NewString(), + UserID: user.ID, + Email: user.Email, + Name: user.Name, + Status: model.AccountDeletionStatusPending, + Attempts: 0, + RequestedAt: now, + } + if reason := strings.TrimSpace(input.Reason); reason != "" { + req.Reason = &reason + } + if ip := strings.TrimSpace(input.IPAddress); ip != "" { + req.IPAddress = &ip + } + + err = repository.WithTx(s.db, func(tx *sqlx.Tx) error { + if err := s.userRepository.MarkPendingDeletionTx(tx, user.ID, now); err != nil { + return fmt.Errorf("flag user pending deletion: %w", err) + } + if err := s.deletionRequestRepo.CreateTx(tx, req); err != nil { + return fmt.Errorf("enqueue deletion request: %w", err) + } + return nil + }) + if err != nil { + return err + } + + // Wake the worker so it picks up immediately rather than waiting for the + // next tick. Non-blocking so a busy/unbuffered channel never stalls the + // HTTP request. + if s.triggerDeletion != nil { + select { + case s.triggerDeletion <- struct{}{}: + default: + } + } + + slog.Info("account deletion requested", "user_id", user.ID, "request_id", req.ID) + return nil +} + +// ProcessPendingDeletions drains all currently pending requests, processing +// each one in its own transaction. Safe to invoke from a ticker, on startup, +// and on demand right after enqueueing. Returns the number of requests +// completed in this call. +func (s *UserService) ProcessPendingDeletions() int { + processed := 0 + for { + req, err := s.deletionRequestRepo.ClaimNextPending() + if errors.Is(err, repository.ErrAccountDeletionRequestNotFound) { + return processed + } + if err != nil { + slog.Error("failed to claim deletion request", "error", err) + return processed + } + + if err := s.executeDeletion(req); err != nil { + s.handleDeletionFailure(req, err) + continue + } + slog.Info("account deletion completed", "user_id", req.UserID, "request_id", req.ID, "attempt", req.Attempts) + processed++ + } +} + +func (s *UserService) executeDeletion(req *model.AccountDeletionRequest) error { + return repository.WithTx(s.db, func(tx *sqlx.Tx) error { + // space_audit_logs and transaction_audit_logs have no FK to their + // parent rows, so we drop them explicitly before the spaces are gone + // or they'd become orphans. + if _, err := tx.Exec( + `DELETE FROM transaction_audit_logs + WHERE transaction_id IN ( + SELECT t.id FROM transactions t + JOIN accounts a ON a.id = t.account_id + JOIN spaces s ON s.id = a.space_id + WHERE s.owner_id = $1 + );`, + req.UserID, + ); err != nil { + return fmt.Errorf("delete transaction audit logs: %w", err) + } + + if _, err := tx.Exec( + `DELETE FROM space_audit_logs + WHERE space_id IN (SELECT id FROM spaces WHERE owner_id = $1);`, + req.UserID, + ); err != nil { + return fmt.Errorf("delete space audit logs: %w", err) + } + + // Cascades accounts, transactions, allocations, recurring events, + // tags, members, and pending invitations on each space. + result, err := tx.Exec(`DELETE FROM spaces WHERE owner_id = $1;`, req.UserID) + if err != nil { + return fmt.Errorf("delete owned spaces: %w", err) + } + spacesDeleted, _ := result.RowsAffected() + + // Remove the user. Cascades tokens, space memberships in spaces + // owned by others, and invitations the user sent. + if _, err := tx.Exec(`DELETE FROM users WHERE id = $1;`, req.UserID); err != nil { + return fmt.Errorf("delete user: %w", err) + } + + if err := s.deletionRequestRepo.MarkCompletedTx(tx, req.ID, int(spacesDeleted)); err != nil { + return fmt.Errorf("mark request completed: %w", err) + } + return nil + }) +} + +func (s *UserService) handleDeletionFailure(req *model.AccountDeletionRequest, deletionErr error) { + msg := deletionErr.Error() + if req.Attempts >= maxAccountDeletionAttempts { + slog.Error("account deletion permanently failed", "request_id", req.ID, "user_id", req.UserID, "attempts", req.Attempts, "error", msg) + if err := s.deletionRequestRepo.MarkFailedTerminal(req.ID, msg); err != nil { + slog.Error("failed to mark request terminal", "error", err, "request_id", req.ID) + } + return + } + slog.Warn("account deletion attempt failed, will retry", "request_id", req.ID, "user_id", req.UserID, "attempt", req.Attempts, "error", msg) + if err := s.deletionRequestRepo.MarkFailedRetryable(req.ID, msg); err != nil { + slog.Error("failed to mark request retryable", "error", err, "request_id", req.ID) + } +} diff --git a/internal/service/user_test.go b/internal/service/user_test.go index 21d112c..aa9ef79 100644 --- a/internal/service/user_test.go +++ b/internal/service/user_test.go @@ -12,7 +12,7 @@ import ( func TestUserService_ByID(t *testing.T) { testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) { userRepo := repository.NewUserRepository(dbi.DB) - svc := NewUserService(userRepo) + svc := NewUserService(dbi.DB, userRepo, repository.NewAccountDeletionRequestRepository(dbi.DB)) user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil) @@ -26,7 +26,7 @@ func TestUserService_ByID(t *testing.T) { func TestUserService_ByID_NotFound(t *testing.T) { testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) { userRepo := repository.NewUserRepository(dbi.DB) - svc := NewUserService(userRepo) + svc := NewUserService(dbi.DB, userRepo, repository.NewAccountDeletionRequestRepository(dbi.DB)) _, err := svc.ByID("nonexistent-id") assert.Error(t, err) diff --git a/internal/ui/pages/account_pending_deletion.templ b/internal/ui/pages/account_pending_deletion.templ new file mode 100644 index 0000000..b681ac6 --- /dev/null +++ b/internal/ui/pages/account_pending_deletion.templ @@ -0,0 +1,52 @@ +package pages + +import ( + "time" + + "git.juancwu.dev/juancwu/budgit/internal/ui/components/button" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/card" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/icon" + "git.juancwu.dev/juancwu/budgit/internal/ui/layouts" +) + +templ AccountPendingDeletion(requestedAt time.Time) { + @layouts.Auth(layouts.SEOProps{ + Title: "Account Pending Deletion", + Description: "Your account is being deleted", + Path: "/account-pending-deletion", + }) { +
+ @card.Card(card.Props{Class: "border-destructive"}) { + @card.Header() { + @card.Title(card.TitleProps{Class: "text-destructive flex items-center gap-2"}) { + @icon.Trash2() + Account Pending Deletion + } + @card.Description() { + You requested to delete your account on { requestedAt.Format("January 2, 2006 at 3:04 PM MST") }. Your data is being permanently removed in the background and this typically finishes within a few minutes. + } + } + @card.Content() { +

+ While the deletion is in progress, you can no longer view or change anything in the app. Once it completes, your session will end and you'll be returned to the home page. +

+

+ If you believe this was a mistake, please contact support immediately — we may be able to halt the deletion before it completes. +

+ } + @card.Footer(card.FooterProps{Class: "justify-end"}) { +
+ @csrf.Token() + @button.Button(button.Props{ + Type: button.TypeSubmit, + Variant: button.VariantOutline, + }) { + Sign out + } +
+ } + } +
+ } +} diff --git a/internal/ui/pages/app_settings.templ b/internal/ui/pages/app_settings.templ index 6d78cf4..9ad2d79 100644 --- a/internal/ui/pages/app_settings.templ +++ b/internal/ui/pages/app_settings.templ @@ -7,17 +7,19 @@ import ( "git.juancwu.dev/juancwu/budgit/internal/ui/components/button" "git.juancwu.dev/juancwu/budgit/internal/ui/components/card" "git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog" "git.juancwu.dev/juancwu/budgit/internal/ui/components/form" "git.juancwu.dev/juancwu/budgit/internal/ui/components/icon" "git.juancwu.dev/juancwu/budgit/internal/ui/components/input" "git.juancwu.dev/juancwu/budgit/internal/ui/components/label" "git.juancwu.dev/juancwu/budgit/internal/ui/components/sidebar" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/textarea" "git.juancwu.dev/juancwu/budgit/internal/ui/layouts" ) -templ AppSettings(hasPassword bool, errorMsg string) { +templ AppSettings(hasPassword bool, email string, passwordError string, deleteError string) { @layouts.App("Settings", spaceOverviewSidebarContent(), settingsSidebarContent()) { -
+
@blocks.PageHeader("Settings", "Manage your account settings") @card.Card() { @card.Header() { @@ -52,7 +54,7 @@ templ AppSettings(hasPassword bool, errorMsg string) { Name: "current_password", Type: input.TypePassword, Placeholder: "••••••••", - HasError: errorMsg != "", + HasError: passwordError != "", }) } } @@ -68,7 +70,7 @@ templ AppSettings(hasPassword bool, errorMsg string) { Name: "new_password", Type: input.TypePassword, Placeholder: "••••••••", - HasError: errorMsg != "", + HasError: passwordError != "", }) } @form.Item() { @@ -83,11 +85,11 @@ templ AppSettings(hasPassword bool, errorMsg string) { Name: "confirm_password", Type: input.TypePassword, Placeholder: "••••••••", - HasError: errorMsg != "", + HasError: passwordError != "", }) - if errorMsg != "" { + if passwordError != "" { @form.Message(form.MessageProps{Variant: form.MessageVariantError}) { - { errorMsg } + { passwordError } } } } @@ -101,10 +103,102 @@ templ AppSettings(hasPassword bool, errorMsg string) { } } + @dangerZone(email, deleteError)
} } +templ dangerZone(email string, deleteError string) { + @card.Card(card.Props{Class: "rounded-sm border-destructive"}) { + @card.Header() { + @card.Title(card.TitleProps{Class: "text-destructive"}) { + Danger Zone + } + @card.Description() { + Permanently delete your account and every space you own. This wipes all of your data — accounts, transactions, allocations, members you invited, and audit history for spaces you own. This cannot be undone. + } + } + @card.Footer(card.FooterProps{Class: "flex justify-end pt-8"}) { + @dialog.Dialog() { + @dialog.Trigger() { + @button.Button(button.Props{ + Variant: button.VariantDestructive, + Class: "flex gap-2 items-center", + }) { + @icon.Trash2() + Delete Account + } + } + @dialog.Content() { +
+ @csrf.Token() + @dialog.Header() { + @dialog.Title() { + Delete your account? + } + @dialog.Description() { + This permanently deletes your account along with all spaces you own and the data inside them. We keep a small internal audit record of the deletion request, but your data itself is gone for good. + } + } +
+ @form.Item() { + @label.Label(label.Props{ + For: "confirm_email", + Class: "block mb-2", + }) { + Type your email { email } to confirm + } + @input.Input(input.Props{ + ID: "confirm_email", + Name: "confirm_email", + Type: input.TypeEmail, + Placeholder: email, + HasError: deleteError != "", + Required: true, + }) + if deleteError != "" { + @form.Message(form.MessageProps{Variant: form.MessageVariantError}) { + { deleteError } + } + } + } + @form.Item() { + @label.Label(label.Props{ + For: "reason", + Class: "block mb-2", + }) { + Reason (optional) + } + @textarea.Textarea(textarea.Props{ + ID: "reason", + Name: "reason", + Placeholder: "Help us understand why you're leaving", + Rows: 3, + }) + } +
+ @dialog.Footer(dialog.FooterProps{Class: "mt-2"}) { + @dialog.Close() { + @button.Button(button.Props{Variant: button.VariantOutline, Type: button.TypeButton}) { + Cancel + } + } + @button.Button(button.Props{ + Type: button.TypeSubmit, + Variant: button.VariantDestructive, + Class: "flex gap-2 items-center", + }) { + @icon.Trash2() + Permanently Delete + } + } +
+ } + } + } + } +} + templ settingsSidebarContent() { @sidebar.Group() { @sidebar.GroupLabel() { diff --git a/internal/worker/account_deletion.go b/internal/worker/account_deletion.go new file mode 100644 index 0000000..0bef791 --- /dev/null +++ b/internal/worker/account_deletion.go @@ -0,0 +1,57 @@ +package worker + +import ( + "context" + "log/slog" + "time" + + "git.juancwu.dev/juancwu/budgit/internal/service" +) + +// AccountDeletionWorker periodically drains the account deletion queue. It +// also exposes a trigger channel so the HTTP handler can wake the worker +// immediately after enqueueing a new request. On startup the worker runs one +// pass synchronously so requests that were in-flight when the server went +// down are resumed before the first new request arrives. +type AccountDeletionWorker struct { + userService *service.UserService + interval time.Duration + trigger chan struct{} +} + +func NewAccountDeletionWorker(userService *service.UserService, interval time.Duration) *AccountDeletionWorker { + w := &AccountDeletionWorker{ + userService: userService, + interval: interval, + trigger: make(chan struct{}, 1), + } + userService.SetDeletionTrigger(w.trigger) + return w +} + +// Start runs an initial pass to resume any work in-flight from a previous +// boot, then loops until ctx is cancelled, processing whenever the ticker +// fires or a trigger arrives. +func (w *AccountDeletionWorker) Start(ctx context.Context) { + slog.Info("account deletion worker starting", "interval", w.interval) + + // Resume work from before the last restart. + if n := w.userService.ProcessPendingDeletions(); n > 0 { + slog.Info("account deletion worker resumed pending work on startup", "processed", n) + } + + ticker := time.NewTicker(w.interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + slog.Info("account deletion worker shutting down") + return + case <-ticker.C: + w.userService.ProcessPendingDeletions() + case <-w.trigger: + w.userService.ProcessPendingDeletions() + } + } +}