diff --git a/go-backend/cmd/api/main.go b/go-backend/cmd/api/main.go
index 370ccf1..fc4cd0f 100644
--- a/go-backend/cmd/api/main.go
+++ b/go-backend/cmd/api/main.go
@@ -160,6 +160,8 @@ func main() {
adminHandler := handlers.NewAdminHandler(dbPool, moderationService, appealService, emailService, openRouterService, cfg.JWTSecret, cfg.TurnstileSecretKey, s3Client, cfg.R2MediaBucket, cfg.R2VideoBucket, cfg.R2ImgDomain, cfg.R2VidDomain)
+ accountHandler := handlers.NewAccountHandler(userRepo, emailService, cfg)
+
mediaHandler := handlers.NewMediaHandler(
s3Client,
cfg.R2AccountID,
@@ -360,6 +362,16 @@ func main() {
authorized.DELETE("/notifications/device", notificationHandler.UnregisterDevice)
authorized.DELETE("/notifications/devices", notificationHandler.UnregisterAllDevices)
+ // Account Lifecycle routes
+ account := authorized.Group("/account")
+ {
+ account.GET("/status", accountHandler.GetAccountStatus)
+ account.POST("/deactivate", accountHandler.DeactivateAccount)
+ account.DELETE("", accountHandler.DeleteAccount)
+ account.POST("/cancel-deletion", accountHandler.CancelDeletion)
+ account.POST("/destroy", accountHandler.RequestImmediateDestroy)
+ }
+
// Appeal System routes
appeals := authorized.Group("/appeals")
{
@@ -457,6 +469,9 @@ func main() {
// Public claim request endpoint (no auth)
r.POST("/api/v1/username-claim", adminHandler.SubmitClaimRequest)
+ // Account destroy confirmation (accessed via email link, no auth)
+ r.GET("/api/v1/account/destroy/confirm", accountHandler.ConfirmImmediateDestroy)
+
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: r,
@@ -468,6 +483,27 @@ func main() {
}
}()
+ // Background job: purge accounts past 14-day deletion window (runs every hour)
+ go func() {
+ ticker := time.NewTicker(1 * time.Hour)
+ defer ticker.Stop()
+ for range ticker.C {
+ ids, err := userRepo.GetAccountsPendingPurge(context.Background())
+ if err != nil {
+ log.Error().Err(err).Msg("[Purge] Failed to fetch accounts pending purge")
+ continue
+ }
+ for _, id := range ids {
+ log.Warn().Str("user_id", id).Msg("[Purge] Auto-purging account past 14-day grace period")
+ if err := userRepo.CascadePurgeUser(context.Background(), id); err != nil {
+ log.Error().Err(err).Str("user_id", id).Msg("[Purge] FAILED to purge account")
+ } else {
+ log.Info().Str("user_id", id).Msg("[Purge] Account permanently destroyed")
+ }
+ }
+ }
+ }()
+
log.Info().Msgf("Server started on port %s", cfg.Port)
quit := make(chan os.Signal, 1)
diff --git a/go-backend/internal/handlers/account_handler.go b/go-backend/internal/handlers/account_handler.go
new file mode 100644
index 0000000..0df5f56
--- /dev/null
+++ b/go-backend/internal/handlers/account_handler.go
@@ -0,0 +1,306 @@
+package handlers
+
+import (
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/patbritton/sojorn-backend/internal/config"
+ "github.com/patbritton/sojorn-backend/internal/repository"
+ "github.com/patbritton/sojorn-backend/internal/services"
+ "github.com/rs/zerolog/log"
+)
+
+type AccountHandler struct {
+ repo *repository.UserRepository
+ emailService *services.EmailService
+ config *config.Config
+}
+
+func NewAccountHandler(repo *repository.UserRepository, emailService *services.EmailService, cfg *config.Config) *AccountHandler {
+ return &AccountHandler{repo: repo, emailService: emailService, config: cfg}
+}
+
+// DeactivateAccount sets the user's status to deactivated.
+// All data is preserved indefinitely. User can reactivate by logging in.
+func (h *AccountHandler) DeactivateAccount(c *gin.Context) {
+ userIDStr, _ := c.Get("user_id")
+ userID := userIDStr.(string)
+
+ user, err := h.repo.GetUserByID(c.Request.Context(), userID)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get account"})
+ return
+ }
+
+ if user.Status == "deactivated" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Account is already deactivated"})
+ return
+ }
+
+ if err := h.repo.DeactivateUser(c.Request.Context(), userID); err != nil {
+ log.Error().Err(err).Str("user_id", userID).Msg("Failed to deactivate account")
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to deactivate account"})
+ return
+ }
+
+ // Revoke all tokens so they're logged out everywhere
+ _ = h.repo.RevokeAllUserTokens(c.Request.Context(), userID)
+
+ log.Info().Str("user_id", userID).Msg("Account deactivated")
+ c.JSON(http.StatusOK, gin.H{
+ "message": "Your account has been deactivated. All your data is preserved. You can reactivate at any time by logging back in.",
+ "status": "deactivated",
+ })
+}
+
+// DeleteAccount schedules the account for deletion after 14 days.
+// During the grace period, the user can cancel by logging back in.
+func (h *AccountHandler) DeleteAccount(c *gin.Context) {
+ userIDStr, _ := c.Get("user_id")
+ userID := userIDStr.(string)
+
+ if err := h.repo.ScheduleDeletion(c.Request.Context(), userID); err != nil {
+ log.Error().Err(err).Str("user_id", userID).Msg("Failed to schedule account deletion")
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to schedule deletion"})
+ return
+ }
+
+ // Revoke all tokens
+ _ = h.repo.RevokeAllUserTokens(c.Request.Context(), userID)
+
+ deletionDate := time.Now().Add(14 * 24 * time.Hour).Format("January 2, 2006")
+
+ log.Info().Str("user_id", userID).Msg("Account scheduled for deletion in 14 days")
+ c.JSON(http.StatusOK, gin.H{
+ "message": fmt.Sprintf("Your account is scheduled for permanent deletion on %s. Log back in before then to cancel. After that date, all data will be irreversibly destroyed.", deletionDate),
+ "status": "pending_deletion",
+ "deletion_date": deletionDate,
+ })
+}
+
+// RequestImmediateDestroy initiates the super-delete flow.
+// Sends a confirmation email with a one-time token. Nothing is deleted yet.
+func (h *AccountHandler) RequestImmediateDestroy(c *gin.Context) {
+ userIDStr, _ := c.Get("user_id")
+ userID := userIDStr.(string)
+
+ user, err := h.repo.GetUserByID(c.Request.Context(), userID)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get account"})
+ return
+ }
+
+ profile, _ := h.repo.GetProfileByID(c.Request.Context(), userID)
+ displayName := "there"
+ if profile != nil && profile.DisplayName != nil {
+ displayName = *profile.DisplayName
+ }
+
+ // Generate a secure one-time token
+ rawToken, err := generateDestroyToken()
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate confirmation token"})
+ return
+ }
+
+ hash := sha256.Sum256([]byte(rawToken))
+ hashString := hex.EncodeToString(hash[:])
+
+ // Store as auth_token with type 'destroy_confirm', expires in 1 hour
+ if err := h.repo.CreateVerificationToken(c.Request.Context(), hashString, userID, 1*time.Hour); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to store confirmation token"})
+ return
+ }
+
+ // Send the confirmation email with strong language
+ go func() {
+ if err := h.sendDestroyConfirmationEmail(user.Email, displayName, rawToken); err != nil {
+ log.Error().Err(err).Str("user_id", userID).Msg("Failed to send destroy confirmation email")
+ }
+ }()
+
+ log.Warn().Str("user_id", userID).Msg("Immediate destroy requested — confirmation email sent")
+ c.JSON(http.StatusOK, gin.H{
+ "message": "A confirmation email has been sent. You must click the link in that email to permanently and immediately destroy your account. This action cannot be undone.",
+ })
+}
+
+// ConfirmImmediateDestroy is the endpoint hit from the email confirmation link.
+// It verifies the token and permanently purges all user data.
+func (h *AccountHandler) ConfirmImmediateDestroy(c *gin.Context) {
+ token := c.Query("token")
+ if token == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Missing confirmation token"})
+ return
+ }
+
+ hash := sha256.Sum256([]byte(token))
+ hashString := hex.EncodeToString(hash[:])
+
+ userID, expiresAt, err := h.repo.GetVerificationToken(c.Request.Context(), hashString)
+ if err != nil {
+ c.Redirect(http.StatusFound, h.config.AppBaseURL+"/verify-error?reason=invalid")
+ return
+ }
+
+ if time.Now().After(expiresAt) {
+ _ = h.repo.DeleteVerificationToken(c.Request.Context(), hashString)
+ c.Redirect(http.StatusFound, h.config.AppBaseURL+"/verify-error?reason=expired")
+ return
+ }
+
+ // Clean up the token
+ _ = h.repo.DeleteVerificationToken(c.Request.Context(), hashString)
+
+ // Revoke all tokens first
+ _ = h.repo.RevokeAllUserTokens(c.Request.Context(), userID)
+
+ // CASCADE PURGE — point of no return
+ if err := h.repo.CascadePurgeUser(c.Request.Context(), userID); err != nil {
+ log.Error().Err(err).Str("user_id", userID).Msg("CRITICAL: Cascade purge failed")
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Account destruction failed. Please contact support."})
+ return
+ }
+
+ log.Warn().Str("user_id", userID).Msg("ACCOUNT DESTROYED — all data permanently purged")
+
+ // Return a simple HTML goodbye page (this is accessed via browser from email link)
+ c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(`
+
Account Destroyed
+
+Account Destroyed
+
Your account and all associated data have been permanently destroyed. This action cannot be undone.
+
Goodbye. — Sojorn
`))
+}
+
+// GetAccountStatus returns the current account lifecycle status
+func (h *AccountHandler) GetAccountStatus(c *gin.Context) {
+ userIDStr, _ := c.Get("user_id")
+ userID := userIDStr.(string)
+
+ user, err := h.repo.GetUserByID(c.Request.Context(), userID)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get account"})
+ return
+ }
+
+ result := gin.H{
+ "status": user.Status,
+ }
+
+ if user.DeletedAt != nil {
+ result["deletion_scheduled"] = user.DeletedAt.Format(time.RFC3339)
+ }
+
+ c.JSON(http.StatusOK, result)
+}
+
+// CancelDeletion allows a user to cancel a pending deletion
+func (h *AccountHandler) CancelDeletion(c *gin.Context) {
+ userIDStr, _ := c.Get("user_id")
+ userID := userIDStr.(string)
+
+ user, err := h.repo.GetUserByID(c.Request.Context(), userID)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get account"})
+ return
+ }
+
+ if string(user.Status) != "pending_deletion" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Account is not pending deletion"})
+ return
+ }
+
+ if err := h.repo.CancelDeletion(c.Request.Context(), userID); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to cancel deletion"})
+ return
+ }
+
+ log.Info().Str("user_id", userID).Msg("Account deletion cancelled, reactivated")
+ c.JSON(http.StatusOK, gin.H{
+ "message": "Deletion cancelled. Your account is active again.",
+ "status": "active",
+ })
+}
+
+// --- helpers ---
+
+func generateDestroyToken() (string, error) {
+ b := make([]byte, 32)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(b), nil
+}
+
+func (h *AccountHandler) sendDestroyConfirmationEmail(toEmail, toName, token string) error {
+ subject := "FINAL WARNING: Confirm Permanent Account Destruction"
+
+ confirmURL := fmt.Sprintf("%s/api/v1/account/destroy/confirm?token=%s",
+ h.config.APIBaseURL, token)
+
+ htmlBody := fmt.Sprintf(`
+
+
+
+
+
+
⚠ Account Destruction Confirmation
+
+
Hey %s,
+
+
You requested immediate and permanent destruction of your Sojorn account.
+
+
+
THIS ACTION IS IRREVERSIBLE
+
+- All your posts, comments, and media will be permanently deleted
+- All your messages and encryption keys will be destroyed
+- Your profile, followers, and social connections will be erased
+- Your handle will be released and cannot be reclaimed
+- There is no recovery. No backup. No undo.
+
+
+
+
If you are absolutely certain, click the button below. Your account will be destroyed the instant you click.
+
+
+
+
This link expires in 1 hour. If you did not request this, you can safely ignore this email — your account will not be affected.
+
+
+
MPLS LLC · Sojorn · Minneapolis, MN
+
+
+`, toName, confirmURL)
+
+ textBody := fmt.Sprintf(`FINAL WARNING: Confirm Permanent Account Destruction
+
+Hey %s,
+
+You requested IMMEDIATE AND PERMANENT DESTRUCTION of your Sojorn account.
+
+THIS ACTION IS IRREVERSIBLE:
+- All posts, comments, and media permanently deleted
+- All messages and encryption keys destroyed
+- Profile, followers, and connections erased
+- Handle released and cannot be reclaimed
+- THERE IS NO RECOVERY. NO BACKUP. NO UNDO.
+
+To confirm, visit: %s
+
+This link expires in 1 hour. If you did not request this, ignore this email.
+
+MPLS LLC - Sojorn - Minneapolis, MN`, toName, confirmURL)
+
+ return h.emailService.SendGenericEmail(toEmail, toName, subject, htmlBody, textBody)
+}
diff --git a/go-backend/internal/handlers/auth_handler.go b/go-backend/internal/handlers/auth_handler.go
index c2d0f66..8bf410a 100644
--- a/go-backend/internal/handlers/auth_handler.go
+++ b/go-backend/internal/handlers/auth_handler.go
@@ -267,9 +267,12 @@ func (h *AuthHandler) Login(c *gin.Context) {
c.JSON(http.StatusForbidden, gin.H{"error": "Your account is temporarily suspended. Please try again later.", "code": "suspended"})
return
}
- if user.Status == models.UserStatusDeactivated {
- c.JSON(http.StatusForbidden, gin.H{"error": "Account deactivated"})
- return
+
+ // Auto-reactivate deactivated or pending-deletion accounts on login
+ if user.Status == models.UserStatusDeactivated || user.Status == models.UserStatusPendingDeletion {
+ log.Printf("[Auth] Reactivating %s account for %s", user.Status, req.Email)
+ _ = h.repo.ReactivateUser(c.Request.Context(), user.ID.String())
+ user.Status = models.UserStatusActive
}
if user.MFAEnabled {
diff --git a/go-backend/internal/models/user.go b/go-backend/internal/models/user.go
index 8770ca5..5e9c7cd 100644
--- a/go-backend/internal/models/user.go
+++ b/go-backend/internal/models/user.go
@@ -9,11 +9,12 @@ import (
type UserStatus string
const (
- UserStatusPending UserStatus = "pending"
- UserStatusActive UserStatus = "active"
- UserStatusDeactivated UserStatus = "deactivated"
- UserStatusBanned UserStatus = "banned"
- UserStatusSuspended UserStatus = "suspended"
+ UserStatusPending UserStatus = "pending"
+ UserStatusActive UserStatus = "active"
+ UserStatusDeactivated UserStatus = "deactivated"
+ UserStatusPendingDeletion UserStatus = "pending_deletion"
+ UserStatusBanned UserStatus = "banned"
+ UserStatusSuspended UserStatus = "suspended"
)
type User struct {
diff --git a/go-backend/internal/repository/user_repository.go b/go-backend/internal/repository/user_repository.go
index ebc2607..81b2372 100644
--- a/go-backend/internal/repository/user_repository.go
+++ b/go-backend/internal/repository/user_repository.go
@@ -1328,3 +1328,166 @@ func (r *UserRepository) IsIPBanned(ctx context.Context, ipAddress string) (bool
`, ipAddress).Scan(&exists)
return exists, err
}
+
+// ========================================================================
+// Account Lifecycle: Deactivate, Delete, Destroy
+// ========================================================================
+
+// DeactivateUser sets user status to deactivated, preserves all data
+func (r *UserRepository) DeactivateUser(ctx context.Context, userID string) error {
+ _, err := r.pool.Exec(ctx, `
+ UPDATE public.users
+ SET status = 'deactivated', updated_at = NOW()
+ WHERE id = $1::uuid
+ `, userID)
+ return err
+}
+
+// ReactivateUser sets a deactivated user back to active
+func (r *UserRepository) ReactivateUser(ctx context.Context, userID string) error {
+ _, err := r.pool.Exec(ctx, `
+ UPDATE public.users
+ SET status = 'active', deleted_at = NULL, updated_at = NOW()
+ WHERE id = $1::uuid AND status IN ('deactivated', 'pending_deletion')
+ `, userID)
+ return err
+}
+
+// ScheduleDeletion marks account for deletion after 14 days
+func (r *UserRepository) ScheduleDeletion(ctx context.Context, userID string) error {
+ _, err := r.pool.Exec(ctx, `
+ UPDATE public.users
+ SET status = 'pending_deletion', deleted_at = NOW() + INTERVAL '14 days', updated_at = NOW()
+ WHERE id = $1::uuid
+ `, userID)
+ return err
+}
+
+// CancelDeletion reverts a pending deletion back to active
+func (r *UserRepository) CancelDeletion(ctx context.Context, userID string) error {
+ return r.ReactivateUser(ctx, userID)
+}
+
+// GetAccountsPendingPurge returns user IDs whose deletion grace period has expired
+func (r *UserRepository) GetAccountsPendingPurge(ctx context.Context) ([]string, error) {
+ rows, err := r.pool.Query(ctx, `
+ SELECT id::text FROM public.users
+ WHERE status = 'pending_deletion' AND deleted_at IS NOT NULL AND deleted_at <= NOW()
+ `)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var ids []string
+ for rows.Next() {
+ var id string
+ if err := rows.Scan(&id); err != nil {
+ return nil, err
+ }
+ ids = append(ids, id)
+ }
+ return ids, nil
+}
+
+// CascadePurgeUser permanently deletes ALL user data from every table. Irreversible.
+func (r *UserRepository) CascadePurgeUser(ctx context.Context, userID string) error {
+ tx, err := r.pool.Begin(ctx)
+ if err != nil {
+ return fmt.Errorf("begin tx: %w", err)
+ }
+ defer tx.Rollback(ctx)
+
+ // Order matters: delete from leaf tables first, then parent tables
+ purgeQueries := []string{
+ // Post-related (leaf tables first)
+ `DELETE FROM public.post_reactions WHERE post_id IN (SELECT id FROM public.posts WHERE author_id = $1::uuid)`,
+ `DELETE FROM public.post_likes WHERE post_id IN (SELECT id FROM public.posts WHERE author_id = $1::uuid)`,
+ `DELETE FROM public.post_saves WHERE post_id IN (SELECT id FROM public.posts WHERE author_id = $1::uuid)`,
+ `DELETE FROM public.post_mentions WHERE post_id IN (SELECT id FROM public.posts WHERE author_id = $1::uuid)`,
+ `DELETE FROM public.post_interactions WHERE post_id IN (SELECT id FROM public.posts WHERE author_id = $1::uuid)`,
+ `DELETE FROM public.post_metrics WHERE post_id IN (SELECT id FROM public.posts WHERE author_id = $1::uuid)`,
+ `DELETE FROM public.post_hashtags WHERE post_id IN (SELECT id FROM public.posts WHERE author_id = $1::uuid)`,
+ `DELETE FROM public.post_categories WHERE post_id IN (SELECT id FROM public.posts WHERE author_id = $1::uuid)`,
+ // Also remove this user's likes/saves/reactions on OTHER people's posts
+ `DELETE FROM public.post_reactions WHERE user_id = $1::uuid`,
+ `DELETE FROM public.post_likes WHERE user_id = $1::uuid`,
+ `DELETE FROM public.post_saves WHERE user_id = $1::uuid`,
+ `DELETE FROM public.post_interactions WHERE user_id = $1::uuid`,
+ // Beacon votes
+ `DELETE FROM public.beacon_votes WHERE user_id = $1::uuid`,
+ // Comments on user's posts
+ `DELETE FROM public.comments WHERE post_id IN (SELECT id FROM public.posts WHERE author_id = $1::uuid)`,
+ // User's own comments
+ `DELETE FROM public.comments WHERE author_id = $1::uuid`,
+ // Feed engagement
+ `DELETE FROM public.feed_engagement WHERE user_id = $1::uuid`,
+ // Posts themselves
+ `DELETE FROM public.posts WHERE author_id = $1::uuid`,
+ // Messaging / E2EE
+ `DELETE FROM public.secure_messages WHERE sender_id = $1::uuid OR receiver_id = $1::uuid`,
+ `DELETE FROM public.encrypted_messages WHERE sender_id = $1::uuid OR receiver_id = $1::uuid`,
+ `DELETE FROM public.e2ee_session_state WHERE session_id IN (SELECT id FROM public.e2ee_sessions WHERE user_a = $1::uuid OR user_b = $1::uuid)`,
+ `DELETE FROM public.e2ee_sessions WHERE user_a = $1::uuid OR user_b = $1::uuid`,
+ `DELETE FROM public.encrypted_conversations WHERE participant_a = $1::uuid OR participant_b = $1::uuid`,
+ // Signal keys
+ `DELETE FROM public.one_time_prekeys WHERE user_id = $1::uuid`,
+ `DELETE FROM public.signed_prekeys WHERE user_id = $1::uuid`,
+ `DELETE FROM public.signal_keys WHERE user_id = $1::uuid`,
+ // Social graph
+ `DELETE FROM public.circle_members WHERE user_id = $1::uuid OR member_id = $1::uuid`,
+ `DELETE FROM public.follows WHERE follower_id = $1::uuid OR following_id = $1::uuid`,
+ `DELETE FROM public.blocks WHERE blocker_id = $1::uuid OR blocked_id = $1::uuid`,
+ // Notifications
+ `DELETE FROM public.notifications WHERE user_id = $1::uuid OR actor_id = $1::uuid`,
+ `DELETE FROM public.notification_preferences WHERE user_id = $1::uuid`,
+ `DELETE FROM public.user_fcm_tokens WHERE user_id = $1::uuid`,
+ // Moderation & violations
+ `DELETE FROM public.moderation_flags WHERE user_id = $1::uuid`,
+ `DELETE FROM public.pending_moderation WHERE user_id = $1::uuid`,
+ `DELETE FROM public.user_violation_history WHERE user_id = $1::uuid`,
+ `DELETE FROM public.user_violations WHERE user_id = $1::uuid`,
+ `DELETE FROM public.user_appeals WHERE user_id = $1::uuid`,
+ `DELETE FROM public.content_strikes WHERE user_id = $1::uuid`,
+ `DELETE FROM public.reports WHERE reporter_id = $1::uuid`,
+ `DELETE FROM public.abuse_logs WHERE user_id = $1::uuid`,
+ `DELETE FROM public.user_status_history WHERE user_id = $1::uuid`,
+ // Categories & hashtags
+ `DELETE FROM public.user_category_preferences WHERE user_id = $1::uuid`,
+ `DELETE FROM public.user_category_settings WHERE user_id = $1::uuid`,
+ `DELETE FROM public.hashtag_follows WHERE user_id = $1::uuid`,
+ // Backup & recovery
+ `DELETE FROM public.recovery_shard_submissions WHERE session_id IN (SELECT id FROM public.recovery_sessions WHERE user_id = $1::uuid)`,
+ `DELETE FROM public.recovery_sessions WHERE user_id = $1::uuid`,
+ `DELETE FROM public.recovery_guardians WHERE user_id = $1::uuid OR guardian_id = $1::uuid`,
+ `DELETE FROM public.cloud_backups WHERE user_id = $1::uuid`,
+ `DELETE FROM public.backup_preferences WHERE user_id = $1::uuid`,
+ `DELETE FROM public.sync_codes WHERE user_id = $1::uuid`,
+ `DELETE FROM public.user_devices WHERE user_id = $1::uuid`,
+ // Auth & tokens
+ `DELETE FROM public.auth_tokens WHERE user_id = $1::uuid`,
+ `DELETE FROM public.refresh_tokens WHERE user_id = $1::uuid`,
+ `DELETE FROM public.verification_tokens WHERE user_id = $1::uuid`,
+ `DELETE FROM public.password_reset_tokens WHERE user_id = $1::uuid`,
+ `DELETE FROM public.webauthn_credentials WHERE user_id = $1::uuid`,
+ `DELETE FROM public.user_mfa_secrets WHERE user_id = $1::uuid`,
+ // Settings
+ `DELETE FROM public.user_settings WHERE user_id = $1::uuid`,
+ `DELETE FROM public.profile_privacy_settings WHERE user_id = $1::uuid`,
+ // Trust
+ `DELETE FROM public.trust_state WHERE user_id = $1::uuid`,
+ // Username claims
+ `DELETE FROM public.username_claim_requests WHERE user_id = $1::uuid`,
+ // Profile & user (last)
+ `DELETE FROM public.profiles WHERE id = $1::uuid`,
+ `DELETE FROM public.users WHERE id = $1::uuid`,
+ }
+
+ for _, q := range purgeQueries {
+ if _, err := tx.Exec(ctx, q, userID); err != nil {
+ return fmt.Errorf("purge query failed: %s: %w", q[:60], err)
+ }
+ }
+
+ return tx.Commit(ctx)
+}
diff --git a/go-backend/internal/services/email_service.go b/go-backend/internal/services/email_service.go
index 18b01ab..197e759 100644
--- a/go-backend/internal/services/email_service.go
+++ b/go-backend/internal/services/email_service.go
@@ -361,6 +361,11 @@ func (s *EmailService) SendAccountRestoredEmail(toEmail, toName, reason string)
return s.sendEmail(toEmail, toName, subject, htmlBody, textBody)
}
+// SendGenericEmail sends an email with pre-built HTML and text bodies
+func (s *EmailService) SendGenericEmail(toEmail, toName, subject, htmlBody, textBody string) error {
+ return s.sendEmail(toEmail, toName, subject, htmlBody, textBody)
+}
+
func (s *EmailService) AddSubscriber(email, name string) {
// SendPulse Addressbook API implementation omitted for brevity, focusing on email first
// Endpoint: POST /addressbooks/{id}/emails
diff --git a/sojorn_app/lib/screens/profile/profile_settings_screen.dart b/sojorn_app/lib/screens/profile/profile_settings_screen.dart
index a92bfde..5d6a3b3 100644
--- a/sojorn_app/lib/screens/profile/profile_settings_screen.dart
+++ b/sojorn_app/lib/screens/profile/profile_settings_screen.dart
@@ -97,9 +97,21 @@ class _ProfileSettingsScreenState extends ConsumerState {
),
_buildEditTile(
icon: Icons.pause_circle_outline,
- title: 'Deactivation',
+ title: 'Deactivate Account',
color: Colors.orange,
- onTap: () {},
+ onTap: () => _showDeactivateDialog(),
+ ),
+ _buildEditTile(
+ icon: Icons.delete_outline,
+ title: 'Delete Account',
+ color: Colors.red.shade400,
+ onTap: () => _showDeleteDialog(),
+ ),
+ _buildEditTile(
+ icon: Icons.warning_amber_rounded,
+ title: 'Immediate Destroy',
+ color: Colors.red.shade800,
+ onTap: () => _showSuperDeleteDialog(),
),
],
),
@@ -672,6 +684,196 @@ class _ProfileSettingsScreenState extends ConsumerState {
}
}
+ // --- Account Lifecycle Dialogs ---
+
+ void _showDeactivateDialog() {
+ showDialog(
+ context: context,
+ builder: (ctx) => AlertDialog(
+ title: Row(
+ children: [
+ Icon(Icons.pause_circle_outline, color: Colors.orange, size: 24),
+ const SizedBox(width: 8),
+ const Text('Deactivate Account'),
+ ],
+ ),
+ content: const Text(
+ 'Your account will be hidden and you will be logged out. '
+ 'All your data — posts, messages, connections — will be preserved indefinitely.\n\n'
+ 'You can reactivate at any time simply by logging back in.',
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(ctx),
+ child: const Text('Cancel'),
+ ),
+ TextButton(
+ onPressed: () async {
+ Navigator.pop(ctx);
+ await _performDeactivation();
+ },
+ style: TextButton.styleFrom(foregroundColor: Colors.orange),
+ child: const Text('Deactivate'),
+ ),
+ ],
+ ),
+ );
+ }
+
+ void _showDeleteDialog() {
+ showDialog(
+ context: context,
+ builder: (ctx) => AlertDialog(
+ title: Row(
+ children: [
+ Icon(Icons.delete_outline, color: Colors.red.shade400, size: 24),
+ const SizedBox(width: 8),
+ const Text('Delete Account'),
+ ],
+ ),
+ content: const Text(
+ 'Your account will be deactivated immediately and permanently deleted after 14 days.\n\n'
+ 'During those 14 days, you can cancel the deletion by logging back in. '
+ 'After that, ALL data will be irreversibly destroyed — posts, messages, encryption keys, profile, everything.\n\n'
+ 'Are you sure you want to proceed?',
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(ctx),
+ child: const Text('Cancel'),
+ ),
+ TextButton(
+ onPressed: () async {
+ Navigator.pop(ctx);
+ await _performDeletion();
+ },
+ style: TextButton.styleFrom(foregroundColor: Colors.red),
+ child: const Text('Delete My Account'),
+ ),
+ ],
+ ),
+ );
+ }
+
+ void _showSuperDeleteDialog() {
+ showDialog(
+ context: context,
+ builder: (ctx) => AlertDialog(
+ title: Row(
+ children: [
+ Icon(Icons.warning_amber_rounded, color: Colors.red.shade800, size: 24),
+ const SizedBox(width: 8),
+ const Expanded(child: Text('Immediate Destroy', style: TextStyle(color: Colors.red))),
+ ],
+ ),
+ content: Column(
+ mainAxisSize: MainAxisSize.min,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: const [
+ Text(
+ 'THIS IS IRREVERSIBLE.',
+ style: TextStyle(fontWeight: FontWeight.bold, color: Colors.red),
+ ),
+ SizedBox(height: 12),
+ Text(
+ 'This will send a confirmation email to your registered address. '
+ 'When you click the link in that email, your account and ALL associated data '
+ 'will be permanently and immediately destroyed.\n\n'
+ 'There is NO recovery. NO backup. NO undo.\n\n'
+ 'Your posts, messages, encryption keys, profile, followers, and handle '
+ 'will all be erased forever.',
+ ),
+ ],
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.pop(ctx),
+ child: const Text('Cancel'),
+ ),
+ TextButton(
+ onPressed: () async {
+ Navigator.pop(ctx);
+ await _performSuperDelete();
+ },
+ style: TextButton.styleFrom(
+ foregroundColor: Colors.white,
+ backgroundColor: Colors.red.shade800,
+ ),
+ child: const Text('Send Destroy Confirmation Email'),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Future _performDeactivation() async {
+ try {
+ final api = ref.read(apiServiceProvider);
+ await api.callGoApi('/account/deactivate', method: 'POST');
+ if (!mounted) return;
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Account deactivated. Log back in anytime to reactivate.')),
+ );
+ await _signOut();
+ } catch (e) {
+ if (!mounted) return;
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('Failed to deactivate: $e')),
+ );
+ }
+ }
+
+ Future _performDeletion() async {
+ try {
+ final api = ref.read(apiServiceProvider);
+ await api.callGoApi('/account', method: 'DELETE');
+ if (!mounted) return;
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(content: Text('Account scheduled for deletion in 14 days. Log back in to cancel.')),
+ );
+ await _signOut();
+ } catch (e) {
+ if (!mounted) return;
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('Failed to schedule deletion: $e')),
+ );
+ }
+ }
+
+ Future _performSuperDelete() async {
+ try {
+ final api = ref.read(apiServiceProvider);
+ await api.callGoApi('/account/destroy', method: 'POST');
+ if (!mounted) return;
+ showDialog(
+ context: context,
+ barrierDismissible: false,
+ builder: (ctx) => AlertDialog(
+ title: const Text('Confirmation Email Sent'),
+ content: const Text(
+ 'A confirmation email has been sent to your registered address.\n\n'
+ 'You MUST click the link in that email to complete the destruction. '
+ 'If you did not mean to do this, simply ignore the email — your account will not be affected.\n\n'
+ 'The link expires in 1 hour.',
+ ),
+ actions: [
+ TextButton(
+ onPressed: () {
+ Navigator.pop(ctx);
+ },
+ child: const Text('OK'),
+ ),
+ ],
+ ),
+ );
+ } catch (e) {
+ if (!mounted) return;
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('Failed to initiate destroy: $e')),
+ );
+ }
+ }
+
Future _signOut() async {
final authService = ref.read(authServiceProvider);
await NotificationService.instance.removeToken();