feat: notify user on account deletion
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m45s

This commit is contained in:
juancwu 2026-05-17 15:40:03 +00:00
commit 43e6f76c01
12 changed files with 294 additions and 19 deletions

View file

@ -58,7 +58,14 @@ func New(cfg *config.Config) (*App, error) {
accountDeletionRequestRepo := repository.NewAccountDeletionRequestRepository(database)
// Services
userService := service.NewUserService(database, userRepository, accountDeletionRequestRepo)
emailService := service.NewEmailService(
emailClient,
cfg.MailerEmailFrom,
cfg.AppURL,
cfg.AppName,
cfg.IsProduction(),
)
userService := service.NewUserService(database, userRepository, accountDeletionRequestRepo, emailService)
accountDeletionWorker := worker.NewAccountDeletionWorker(userService, 30*time.Second)
auditLogService := service.NewSpaceAuditLogService(auditLogRepository)
txAuditLogService := service.NewTransactionAuditLogService(txAuditLogRepository)
@ -73,13 +80,6 @@ func New(cfg *config.Config) (*App, error) {
transactionService.SetAuditLogger(txAuditLogService)
transactionService.SetAllocationService(allocationService)
accountActivityService := service.NewAccountActivityService(auditLogService, txAuditLogService)
emailService := service.NewEmailService(
emailClient,
cfg.MailerEmailFrom,
cfg.AppURL,
cfg.AppName,
cfg.IsProduction(),
)
authService := service.NewAuthService(
emailService,
userRepository,

View file

@ -77,13 +77,36 @@ func (h *settingsHandler) DeleteAccount(w http.ResponseWriter, r *http.Request)
http.Redirect(w, r, "/account-pending-deletion", http.StatusSeeOther)
}
func (h *settingsHandler) AccountDeletionStatusPage(w http.ResponseWriter, r *http.Request) {
requestID := r.PathValue("requestID")
if requestID == "" {
ui.Render(w, r, pages.NotFound())
return
}
req, err := h.userService.GetDeletionRequest(requestID)
if err != nil {
slog.Info("account deletion status lookup failed",
"request_id", requestID,
"error", err,
)
ui.Render(w, r, pages.NotFound())
return
}
ui.Render(w, r, pages.AccountDeletionStatus(req))
}
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))
trackingID := ""
if req, err := h.userService.LatestDeletionRequestForUser(user.ID); err == nil {
trackingID = req.ID
}
ui.Render(w, r, pages.AccountPendingDeletion(*user.PendingDeletionAt, trackingID))
}
func (h *settingsHandler) SetPassword(w http.ResponseWriter, r *http.Request) {

View file

@ -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(dbi.DB, userRepo, repository.NewAccountDeletionRequestRepository(dbi.DB))
userSvc := service.NewUserService(dbi.DB, userRepo, repository.NewAccountDeletionRequestRepository(dbi.DB), nil)
return NewSettingsHandler(authSvc, userSvc), authSvc
}

View file

@ -33,8 +33,10 @@ func BlockPendingDeletion(next http.Handler) http.HandlerFunc {
return
}
// Always permit static assets so the pending page can render.
if strings.HasPrefix(r.URL.Path, "/assets/") {
// Always permit static assets so the pending page can render, and
// the dynamic deletion-status URL the user got in their email.
if strings.HasPrefix(r.URL.Path, "/assets/") ||
strings.HasPrefix(r.URL.Path, "/account-deletion-status/") {
next.ServeHTTP(w, r)
return
}
@ -48,10 +50,10 @@ func BlockPendingDeletion(next http.Handler) http.HandlerFunc {
// 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))
ui.Render(w, r, pages.AccountPendingDeletion(*user.PendingDeletionAt, ""))
return
}
ui.Render(w, r, pages.AccountPendingDeletion(*user.PendingDeletionAt))
ui.Render(w, r, pages.AccountPendingDeletion(*user.PendingDeletionAt, ""))
}
}

View file

@ -13,7 +13,9 @@ var ErrAccountDeletionRequestNotFound = errors.New("account deletion request not
type AccountDeletionRequestRepository interface {
CreateTx(tx *sqlx.Tx, req *model.AccountDeletionRequest) error
ByID(id string) (*model.AccountDeletionRequest, error)
HasPendingForUser(userID string) (bool, error)
LatestForUser(userID string) (*model.AccountDeletionRequest, error)
// ClaimNextPending atomically transitions the oldest pending request to
// "processing" and returns it. Returns ErrAccountDeletionRequestNotFound
@ -56,6 +58,36 @@ func (r *accountDeletionRequestRepository) CreateTx(tx *sqlx.Tx, req *model.Acco
return err
}
func (r *accountDeletionRequestRepository) ByID(id string) (*model.AccountDeletionRequest, error) {
var req model.AccountDeletionRequest
err := r.db.Get(&req, `SELECT * FROM account_deletion_requests WHERE id = $1;`, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrAccountDeletionRequestNotFound
}
if err != nil {
return nil, err
}
return &req, nil
}
func (r *accountDeletionRequestRepository) LatestForUser(userID string) (*model.AccountDeletionRequest, error) {
var req model.AccountDeletionRequest
err := r.db.Get(&req,
`SELECT * FROM account_deletion_requests
WHERE user_id = $1
ORDER BY requested_at DESC
LIMIT 1;`,
userID,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrAccountDeletionRequestNotFound
}
if err != nil {
return nil, err
}
return &req, nil
}
func (r *accountDeletionRequestRepository) HasPendingForUser(userID string) (bool, error) {
var n int
err := r.db.Get(&n,

View file

@ -57,6 +57,7 @@ func SetupRoutes(a *app.App) http.Handler {
r.Get("/privacy", homeH.PrivacyPage).Name("page.public.privacy")
r.Get("/terms", homeH.TermsPage).Name("page.public.terms")
r.Get("/join/{token}", authH.JoinSpace).Name("page.public.join-space")
r.Get("/account-deletion-status/{requestID}", settingsH.AccountDeletionStatusPage).Name("page.public.account-deletion-status")
r.Post("/join/{token}/accept", authH.AcceptInvite).Name("action.public.join-space.accept")
// Permanent redirects

View file

@ -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(dbi.DB, userRepo, repository.NewAccountDeletionRequestRepository(dbi.DB))
userSvc := service.NewUserService(dbi.DB, userRepo, repository.NewAccountDeletionRequestRepository(dbi.DB), nil)
inviteSvc := service.NewInviteService(inviteRepo, spaceRepo, userRepo, emailSvc, nil)
return &app.App{

View file

@ -224,6 +224,56 @@ func (s *EmailService) SendInvitationEmail(email, spaceName, inviterName, token
return err
}
func (s *EmailService) SendAccountDeletionRequestedEmail(email, name, requestID string) error {
trackURL := fmt.Sprintf("%s/account-deletion-status/%s", s.appURL, requestID)
subject, body := accountDeletionRequestedEmailTemplate(name, trackURL, s.appName)
if !s.isProd {
slog.Info("email sent (dev mode)", "type", "account_deletion_requested", "to", email, "subject", subject, "url", trackURL)
return nil
}
if s.client == nil {
return fmt.Errorf("email service not configured")
}
params := &EmailParams{
From: s.fromEmail,
To: []string{email},
Subject: subject,
Text: body,
}
_, err := s.client.SendWithContext(context.Background(), params)
if err == nil {
slog.Info("email sent", "type", "account_deletion_requested", "to", email)
}
return err
}
func accountDeletionRequestedEmailTemplate(name, trackURL, appName string) (string, string) {
greeting := "Hi,"
if name != "" {
greeting = fmt.Sprintf("Hi %s,", name)
}
subject := fmt.Sprintf("Your %s account deletion request was received", appName)
body := fmt.Sprintf(`%s
We received your request to permanently delete your %s account. The deletion is now in progress and typically finishes within a few minutes.
You can track the status of your request here:
%s
This link is the only way to check on the request keep it somewhere safe if you want to confirm completion later. Once the deletion finishes, your data is gone for good and cannot be recovered.
If you did NOT request this, contact our support team immediately. We may still be able to halt the deletion before it completes.
Best,
The %s Team`, greeting, appName, trackURL, appName)
return subject, body
}
func magicLinkEmailTemplate(magicURL, appName string) (string, string) {
subject := fmt.Sprintf("Sign in to %s", appName)
body := fmt.Sprintf(`Click this link to sign in to your account:

View file

@ -26,6 +26,7 @@ type UserService struct {
db *sqlx.DB
userRepository repository.UserRepository
deletionRequestRepo repository.AccountDeletionRequestRepository
emailService *EmailService
// 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.
@ -36,14 +37,29 @@ func NewUserService(
db *sqlx.DB,
userRepository repository.UserRepository,
deletionRequestRepo repository.AccountDeletionRequestRepository,
emailService *EmailService,
) *UserService {
return &UserService{
db: db,
userRepository: userRepository,
deletionRequestRepo: deletionRequestRepo,
emailService: emailService,
}
}
// GetDeletionRequest fetches a deletion request by ID. Returns
// repository.ErrAccountDeletionRequestNotFound when the ID is unknown.
func (s *UserService) GetDeletionRequest(id string) (*model.AccountDeletionRequest, error) {
return s.deletionRequestRepo.ByID(id)
}
// LatestDeletionRequestForUser returns the most recent deletion request for
// the given user, or repository.ErrAccountDeletionRequestNotFound if there
// is none.
func (s *UserService) LatestDeletionRequestForUser(userID string) (*model.AccountDeletionRequest, error) {
return s.deletionRequestRepo.LatestForUser(userID)
}
func (s *UserService) SetDeletionTrigger(ch chan<- struct{}) {
s.triggerDeletion = ch
}
@ -114,6 +130,18 @@ func (s *UserService) RequestAccountDeletion(input RequestAccountDeletionInput)
return err
}
// Confirmation email — best-effort. The deletion proceeds regardless so
// a transient mail outage doesn't trap the user with a flagged account.
if s.emailService != nil {
name := ""
if user.Name != nil {
name = *user.Name
}
if err := s.emailService.SendAccountDeletionRequestedEmail(user.Email, name, req.ID); err != nil {
slog.Error("failed to send account deletion confirmation email", "error", err, "user_id", user.ID, "request_id", req.ID)
}
}
// 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.

View file

@ -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(dbi.DB, userRepo, repository.NewAccountDeletionRequestRepository(dbi.DB))
svc := NewUserService(dbi.DB, userRepo, repository.NewAccountDeletionRequestRepository(dbi.DB), nil)
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(dbi.DB, userRepo, repository.NewAccountDeletionRequestRepository(dbi.DB))
svc := NewUserService(dbi.DB, userRepo, repository.NewAccountDeletionRequestRepository(dbi.DB), nil)
_, err := svc.ByID("nonexistent-id")
assert.Error(t, err)

View file

@ -0,0 +1,121 @@
package pages
import (
"strconv"
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
"git.juancwu.dev/juancwu/budgit/internal/model"
"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/icon"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
)
func deletionBadgeClass(status string) string {
switch status {
case model.AccountDeletionStatusCompleted:
return "bg-primary text-primary-foreground"
case model.AccountDeletionStatusFailed:
return "bg-destructive text-destructive-foreground"
case model.AccountDeletionStatusProcessing:
return "bg-secondary text-secondary-foreground"
default:
return "border border-input"
}
}
func deletionBadgeText(status string) string {
switch status {
case model.AccountDeletionStatusCompleted:
return "Completed"
case model.AccountDeletionStatusFailed:
return "Failed"
case model.AccountDeletionStatusProcessing:
return "Processing"
default:
return "Pending"
}
}
func deletionBlurb(status string) string {
switch status {
case model.AccountDeletionStatusCompleted:
return "Your data has been permanently deleted. This record is the only thing that remains."
case model.AccountDeletionStatusFailed:
return "We hit a problem we couldn't recover from automatically. Your data is still in place — please reach out to support so we can finish the deletion manually."
case model.AccountDeletionStatusProcessing:
return "Your data is being deleted right now. This usually finishes within a few seconds."
default:
return "Your request is in the queue. The background worker picks it up within 30 seconds."
}
}
templ AccountDeletionStatus(req *model.AccountDeletionRequest) {
{{ cfg := ctxkeys.Config(ctx) }}
@layouts.Auth(layouts.SEOProps{
Title: "Account Deletion Status",
Description: "Track the status of an account deletion request",
Path: "/account-deletion-status",
}) {
<div class="container max-w-xl px-4 py-16 mx-auto">
@card.Card() {
@card.Header() {
@card.Title(card.TitleProps{Class: "flex items-center gap-2"}) {
@icon.Trash2()
<span>Account Deletion Status</span>
}
@card.Description() {
Request for { req.Email }
}
}
@card.Content() {
<dl class="space-y-3 text-sm">
<div class="flex items-center justify-between">
<dt class="text-muted-foreground">Status</dt>
<dd>
<span class={ "inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium", deletionBadgeClass(req.Status) }>
{ deletionBadgeText(req.Status) }
</span>
</dd>
</div>
<div class="flex items-center justify-between">
<dt class="text-muted-foreground">Requested</dt>
<dd>{ req.RequestedAt.Format("January 2, 2006 at 3:04 PM MST") }</dd>
</div>
if req.CompletedAt != nil {
<div class="flex items-center justify-between">
<dt class="text-muted-foreground">Completed</dt>
<dd>{ req.CompletedAt.Format("January 2, 2006 at 3:04 PM MST") }</dd>
</div>
}
if req.Attempts > 0 {
<div class="flex items-center justify-between">
<dt class="text-muted-foreground">Attempts</dt>
<dd>{ strconv.Itoa(req.Attempts) }</dd>
</div>
}
if req.SpacesDeleted != nil {
<div class="flex items-center justify-between">
<dt class="text-muted-foreground">Spaces deleted</dt>
<dd>{ strconv.Itoa(*req.SpacesDeleted) }</dd>
</div>
}
</dl>
<p class="mt-6 text-sm text-muted-foreground">{ deletionBlurb(req.Status) }</p>
if req.LastError != nil {
<p class="mt-4 text-sm text-destructive">Last error: { *req.LastError }</p>
<p class="mt-2 text-sm text-muted-foreground">Please contact support at <a href={ templ.URL("mailto:" + cfg.SupportEmail) } class="text-primary hover:underline">{ cfg.SupportEmail }</a></p>
}
}
@card.Footer(card.FooterProps{Class: "justify-between"}) {
@button.Button(button.Props{Href: "/", Variant: button.VariantGhost}) {
Back to home
}
@button.Button(button.Props{Href: "/account-deletion-status/" + req.ID, Variant: button.VariantOutline}) {
Refresh
}
}
}
</div>
}
}

View file

@ -10,7 +10,7 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
)
templ AccountPendingDeletion(requestedAt time.Time) {
templ AccountPendingDeletion(requestedAt time.Time, trackingID string) {
@layouts.Auth(layouts.SEOProps{
Title: "Account Pending Deletion",
Description: "Your account is being deleted",
@ -34,8 +34,26 @@ templ AccountPendingDeletion(requestedAt time.Time) {
<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>
if trackingID != "" {
<p class="text-sm text-muted-foreground mt-4">
We also emailed you a confirmation. You can check the status of your deletion any time at
<a class="text-primary hover:underline break-all" href={ templ.SafeURL("/account-deletion-status/" + trackingID) }>
/account-deletion-status/{ trackingID }
</a>.
</p>
}
}
@card.Footer(card.FooterProps{Class: "justify-end"}) {
@card.Footer(card.FooterProps{Class: "justify-between"}) {
if trackingID != "" {
@button.Button(button.Props{
Href: "/account-deletion-status/" + trackingID,
Variant: button.VariantGhost,
}) {
Track status
}
} else {
<span></span>
}
<form action="/auth/logout" method="POST">
@csrf.Token()
@button.Button(button.Props{