sojorn/go-backend/internal/handlers/account_handler.go
Patrick Britton da5984d67c refactor: rename Go module from github.com/patbritton to gitlab.com/patrickbritton3
- Rename module path from github.com/patbritton/sojorn-backend to gitlab.com/patrickbritton3/sojorn/go-backend
- Updated 78 references across 41 files
- Matches new GitLab repository structure
2026-02-16 23:58:39 -06:00

275 lines
9.2 KiB
Go

package handlers
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/config"
"gitlab.com/patrickbritton3/sojorn/go-backend/internal/repository"
"gitlab.com/patrickbritton3/sojorn/go-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")
c.Redirect(http.StatusFound, h.config.AppBaseURL+"/destroyed")
}
// 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) sendDeactivationEmail(toEmail, toName string) error {
return h.emailService.SendDeactivationEmail(toEmail, toName)
}
func (h *AccountHandler) sendDeletionScheduledEmail(toEmail, toName, deletionDate string) error {
return h.emailService.SendDeletionScheduledEmail(toEmail, toName, deletionDate)
}
func (h *AccountHandler) sendDestroyConfirmationEmail(toEmail, toName, token string) error {
confirmURL := fmt.Sprintf("%s/api/v1/account/destroy/confirm?token=%s",
h.config.APIBaseURL, token)
return h.emailService.SendDestroyConfirmationEmail(toEmail, toName, confirmURL)
}