feat: notify user on account deletion
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m45s
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m45s
This commit is contained in:
parent
2db260f849
commit
43e6f76c01
12 changed files with 294 additions and 19 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue