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) // Send notification email profile, _ := h.repo.GetProfileByID(c.Request.Context(), userID) displayName := "there" if profile != nil && profile.DisplayName != nil { displayName = *profile.DisplayName } go func() { if err := h.sendDeactivationEmail(user.Email, displayName); err != nil { log.Error().Err(err).Str("user_id", userID).Msg("Failed to send deactivation email") } }() 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") // Send notification email user, err := h.repo.GetUserByID(c.Request.Context(), userID) if err == nil { profile, _ := h.repo.GetProfileByID(c.Request.Context(), userID) displayName := "there" if profile != nil && profile.DisplayName != nil { displayName = *profile.DisplayName } go func() { if err := h.sendDeletionScheduledEmail(user.Email, displayName, deletionDate); err != nil { log.Error().Err(err).Str("user_id", userID).Msg("Failed to send deletion email") } }() } 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(`
Your account and all associated data have been permanently destroyed. This action cannot be undone.
Goodbye. — Sojorn
Hey %s,
Your Sojorn account has been deactivated. Your profile is now hidden from other users.
What this means:
If you did not request this, please log back in immediately to reactivate your account and secure it by changing your password.
MPLS LLC · Sojorn · Minneapolis, MN
Hey %s,
Your Sojorn account has been scheduled for permanent deletion on %s.
What happens next:
Changed your mind?
Simply log back in before %s to cancel the deletion and reactivate your account. All your data is still intact during the grace period.
If you did not request this, please log back in immediately to cancel the deletion and secure your account.
MPLS LLC · Sojorn · Minneapolis, MN
Hey %s,
You requested immediate and permanent destruction of your Sojorn account.
THIS ACTION IS IRREVERSIBLE
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