feat: account deletion
This commit is contained in:
parent
4769760b93
commit
2db260f849
20 changed files with 785 additions and 29 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
57
internal/middleware/pending_deletion.go
Normal file
57
internal/middleware/pending_deletion.go
Normal file
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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 != "" {
|
||||
|
|
|
|||
30
internal/model/account_deletion_request.go
Normal file
30
internal/model/account_deletion_request.go
Normal file
|
|
@ -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"`
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
132
internal/repository/account_deletion_request.go
Normal file
132
internal/repository/account_deletion_request.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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;`
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
52
internal/ui/pages/account_pending_deletion.templ
Normal file
52
internal/ui/pages/account_pending_deletion.templ
Normal file
|
|
@ -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",
|
||||
}) {
|
||||
<div class="container max-w-xl px-4 py-16 mx-auto">
|
||||
@card.Card(card.Props{Class: "border-destructive"}) {
|
||||
@card.Header() {
|
||||
@card.Title(card.TitleProps{Class: "text-destructive flex items-center gap-2"}) {
|
||||
@icon.Trash2()
|
||||
<span>Account Pending Deletion</span>
|
||||
}
|
||||
@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() {
|
||||
<p class="text-sm text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
<p class="text-sm text-muted-foreground mt-4">
|
||||
If you believe this was a mistake, please contact support immediately — we may be able to halt the deletion before it completes.
|
||||
</p>
|
||||
}
|
||||
@card.Footer(card.FooterProps{Class: "justify-end"}) {
|
||||
<form action="/auth/logout" method="POST">
|
||||
@csrf.Token()
|
||||
@button.Button(button.Props{
|
||||
Type: button.TypeSubmit,
|
||||
Variant: button.VariantOutline,
|
||||
}) {
|
||||
Sign out
|
||||
}
|
||||
</form>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
|
@ -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()) {
|
||||
<div class="container max-w-2xl px-6 py-8">
|
||||
<div class="container max-w-2xl px-6 py-8 space-y-8">
|
||||
@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) {
|
|||
</form>
|
||||
}
|
||||
}
|
||||
@dangerZone(email, deleteError)
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
<form action="/app/settings/delete-account" method="POST" class="space-y-4">
|
||||
@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.
|
||||
}
|
||||
}
|
||||
<div class="space-y-4 pt-2">
|
||||
@form.Item() {
|
||||
@label.Label(label.Props{
|
||||
For: "confirm_email",
|
||||
Class: "block mb-2",
|
||||
}) {
|
||||
Type your email <span class="font-mono">{ email }</span> 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,
|
||||
})
|
||||
}
|
||||
</div>
|
||||
@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
|
||||
}
|
||||
}
|
||||
</form>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
templ settingsSidebarContent() {
|
||||
@sidebar.Group() {
|
||||
@sidebar.GroupLabel() {
|
||||
|
|
|
|||
57
internal/worker/account_deletion.go
Normal file
57
internal/worker/account_deletion.go
Normal file
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue