diff --git a/internal/app/app.go b/internal/app/app.go index 22fca04..3215ada 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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, diff --git a/internal/handler/settings.go b/internal/handler/settings.go index 05c0f43..d0b875b 100644 --- a/internal/handler/settings.go +++ b/internal/handler/settings.go @@ -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) { diff --git a/internal/handler/settings_test.go b/internal/handler/settings_test.go index 667bc95..af0457a 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(dbi.DB, userRepo, repository.NewAccountDeletionRequestRepository(dbi.DB)) + userSvc := service.NewUserService(dbi.DB, userRepo, repository.NewAccountDeletionRequestRepository(dbi.DB), nil) return NewSettingsHandler(authSvc, userSvc), authSvc } diff --git a/internal/middleware/pending_deletion.go b/internal/middleware/pending_deletion.go index 248c8e0..709e382 100644 --- a/internal/middleware/pending_deletion.go +++ b/internal/middleware/pending_deletion.go @@ -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, "")) } } diff --git a/internal/repository/account_deletion_request.go b/internal/repository/account_deletion_request.go index 4ce8d7f..32e7278 100644 --- a/internal/repository/account_deletion_request.go +++ b/internal/repository/account_deletion_request.go @@ -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, diff --git a/internal/routes/routes.go b/internal/routes/routes.go index d08c19d..bbb4879 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -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 diff --git a/internal/routes/routes_test.go b/internal/routes/routes_test.go index b277227..0306de1 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(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{ diff --git a/internal/service/email.go b/internal/service/email.go index a482f69..5eade11 100644 --- a/internal/service/email.go +++ b/internal/service/email.go @@ -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: diff --git a/internal/service/user.go b/internal/service/user.go index fa478be..94b2bf8 100644 --- a/internal/service/user.go +++ b/internal/service/user.go @@ -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. diff --git a/internal/service/user_test.go b/internal/service/user_test.go index aa9ef79..543f3e3 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(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) diff --git a/internal/ui/pages/account_deletion_status.templ b/internal/ui/pages/account_deletion_status.templ new file mode 100644 index 0000000..cf5692d --- /dev/null +++ b/internal/ui/pages/account_deletion_status.templ @@ -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", + }) { +
+ @card.Card() { + @card.Header() { + @card.Title(card.TitleProps{Class: "flex items-center gap-2"}) { + @icon.Trash2() + Account Deletion Status + } + @card.Description() { + Request for { req.Email } + } + } + @card.Content() { +
+
+
Status
+
+ + { deletionBadgeText(req.Status) } + +
+
+
+
Requested
+
{ req.RequestedAt.Format("January 2, 2006 at 3:04 PM MST") }
+
+ if req.CompletedAt != nil { +
+
Completed
+
{ req.CompletedAt.Format("January 2, 2006 at 3:04 PM MST") }
+
+ } + if req.Attempts > 0 { +
+
Attempts
+
{ strconv.Itoa(req.Attempts) }
+
+ } + if req.SpacesDeleted != nil { +
+
Spaces deleted
+
{ strconv.Itoa(*req.SpacesDeleted) }
+
+ } +
+

{ deletionBlurb(req.Status) }

+ if req.LastError != nil { +

Last error: { *req.LastError }

+

Please contact support at { cfg.SupportEmail }

+ } + } + @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 + } + } + } +
+ } +} diff --git a/internal/ui/pages/account_pending_deletion.templ b/internal/ui/pages/account_pending_deletion.templ index b681ac6..6ae91b5 100644 --- a/internal/ui/pages/account_pending_deletion.templ +++ b/internal/ui/pages/account_pending_deletion.templ @@ -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) {

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

+ if trackingID != "" { +

+ We also emailed you a confirmation. You can check the status of your deletion any time at + + /account-deletion-status/{ trackingID } + . +

+ } } - @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 { + + }
@csrf.Token() @button.Button(button.Props{