diff --git a/go-backend/cmd/api/main.go b/go-backend/cmd/api/main.go index 452a142..552e4b3 100644 --- a/go-backend/cmd/api/main.go +++ b/go-backend/cmd/api/main.go @@ -154,7 +154,7 @@ func main() { } } - adminHandler := handlers.NewAdminHandler(dbPool, moderationService, appealService, cfg.JWTSecret, cfg.TurnstileSecretKey, s3Client, cfg.R2MediaBucket, cfg.R2VideoBucket, cfg.R2ImgDomain, cfg.R2VidDomain) + adminHandler := handlers.NewAdminHandler(dbPool, moderationService, appealService, emailService, cfg.JWTSecret, cfg.TurnstileSecretKey, s3Client, cfg.R2MediaBucket, cfg.R2VideoBucket, cfg.R2ImgDomain, cfg.R2VidDomain) mediaHandler := handlers.NewMediaHandler( s3Client, diff --git a/go-backend/internal/handlers/admin_handler.go b/go-backend/internal/handlers/admin_handler.go index 52a479f..1d2c209 100644 --- a/go-backend/internal/handlers/admin_handler.go +++ b/go-backend/internal/handlers/admin_handler.go @@ -24,6 +24,7 @@ type AdminHandler struct { pool *pgxpool.Pool moderationService *services.ModerationService appealService *services.AppealService + emailService *services.EmailService jwtSecret string turnstileSecret string s3Client *s3.Client @@ -33,11 +34,12 @@ type AdminHandler struct { vidDomain string } -func NewAdminHandler(pool *pgxpool.Pool, moderationService *services.ModerationService, appealService *services.AppealService, jwtSecret string, turnstileSecret string, s3Client *s3.Client, mediaBucket string, videoBucket string, imgDomain string, vidDomain string) *AdminHandler { +func NewAdminHandler(pool *pgxpool.Pool, moderationService *services.ModerationService, appealService *services.AppealService, emailService *services.EmailService, jwtSecret string, turnstileSecret string, s3Client *s3.Client, mediaBucket string, videoBucket string, imgDomain string, vidDomain string) *AdminHandler { return &AdminHandler{ pool: pool, moderationService: moderationService, appealService: appealService, + emailService: emailService, jwtSecret: jwtSecret, turnstileSecret: turnstileSecret, s3Client: s3Client, @@ -460,10 +462,35 @@ func (h *AdminHandler) UpdateUserStatus(c *gin.Context) { h.pool.QueryRow(ctx, `SELECT status FROM users WHERE id = $1::uuid`, targetUserID).Scan(&oldStatus) // Update user status - _, err := h.pool.Exec(ctx, `UPDATE users SET status = $1 WHERE id = $2::uuid`, req.Status, targetUserID) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user status"}) - return + if req.Status == "banned" { + _, err := h.pool.Exec(ctx, `UPDATE users SET status = 'banned' WHERE id = $1::uuid`, targetUserID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user status"}) + return + } + // Revoke ALL refresh tokens immediately + h.pool.Exec(ctx, `UPDATE refresh_tokens SET revoked = true WHERE user_id = $1::uuid`, targetUserID) + // Log the banned user's last known IP + h.pool.Exec(ctx, ` + INSERT INTO banned_ips (ip_address, user_id, reason, banned_at) + SELECT COALESCE( + (SELECT ip_address FROM audit_log WHERE target_id = $1::uuid ORDER BY created_at DESC LIMIT 1), + 'unknown' + ), $1::uuid, $2, NOW() + `, targetUserID, req.Reason) + } else if req.Status == "suspended" { + suspendUntil := time.Now().Add(7 * 24 * time.Hour) // Default 7 day suspension from admin + _, err := h.pool.Exec(ctx, `UPDATE users SET status = 'suspended', suspended_until = $2 WHERE id = $1::uuid`, targetUserID, suspendUntil) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user status"}) + return + } + } else { + _, err := h.pool.Exec(ctx, `UPDATE users SET status = $1, suspended_until = NULL WHERE id = $2::uuid`, req.Status, targetUserID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user status"}) + return + } } // Log status change @@ -473,6 +500,25 @@ func (h *AdminHandler) UpdateUserStatus(c *gin.Context) { VALUES ($1::uuid, $2, $3, $4, $5) `, targetUserID, oldStatus, req.Status, req.Reason, adminUUID) + // Send notification email + if h.emailService != nil && (req.Status == "banned" || req.Status == "suspended") { + var userEmail, displayName string + h.pool.QueryRow(ctx, `SELECT u.email, COALESCE(p.display_name, '') FROM users u LEFT JOIN profiles p ON p.id = u.id WHERE u.id = $1::uuid`, targetUserID).Scan(&userEmail, &displayName) + if userEmail != "" { + go func() { + if req.Status == "banned" { + if err := h.emailService.SendBanNotificationEmail(userEmail, displayName, req.Reason); err != nil { + log.Error().Err(err).Str("user", targetUserID).Msg("Failed to send ban notification email") + } + } else if req.Status == "suspended" { + if err := h.emailService.SendSuspensionNotificationEmail(userEmail, displayName, req.Reason, "7 days"); err != nil { + log.Error().Err(err).Str("user", targetUserID).Msg("Failed to send suspension notification email") + } + } + }() + } + } + c.JSON(http.StatusOK, gin.H{"message": "User status updated", "status": req.Status}) } @@ -738,6 +784,47 @@ func (h *AdminHandler) UpdatePostStatus(c *gin.Context) { VALUES ($1, $2, 'post', $3::uuid, $4) `, adminUUID, "post_status_change", postID, fmt.Sprintf(`{"status":"%s","reason":"%s"}`, req.Status, req.Reason)) + // If post was removed, record a strike and notify the author + if req.Status == "removed" || req.Status == "flagged" { + var authorID uuid.UUID + var authorEmail, displayName string + err := h.pool.QueryRow(ctx, ` + SELECT p.author_id, u.email, COALESCE(pr.display_name, '') + FROM posts p + JOIN users u ON u.id = p.author_id + LEFT JOIN profiles pr ON pr.id = p.author_id + WHERE p.id = $1::uuid + `, postID).Scan(&authorID, &authorEmail, &displayName) + + if err == nil && req.Status == "removed" { + // Record a strike + reason := req.Reason + if reason == "" { + reason = "Post removed by moderation team" + } + h.pool.Exec(ctx, ` + INSERT INTO content_strikes (user_id, category, content_snippet, created_at) + VALUES ($1, 'moderation', $2, NOW()) + `, authorID, reason) + + // Count strikes + var strikeCount int + h.pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM content_strikes + WHERE user_id = $1 AND created_at > NOW() - INTERVAL '30 days' + `, authorID).Scan(&strikeCount) + + // Send email notification + if h.emailService != nil && authorEmail != "" { + go func() { + if err := h.emailService.SendPostRemovalEmail(authorEmail, displayName, reason, strikeCount); err != nil { + log.Error().Err(err).Str("user", authorID.String()).Msg("Failed to send post removal email") + } + }() + } + } + } + c.JSON(http.StatusOK, gin.H{"message": "Post status updated", "status": req.Status}) } @@ -746,6 +833,17 @@ func (h *AdminHandler) DeletePost(c *gin.Context) { adminID, _ := c.Get("user_id") postID := c.Param("id") + // Get author info before deleting + var authorID uuid.UUID + var authorEmail, displayName string + h.pool.QueryRow(ctx, ` + SELECT p.author_id, u.email, COALESCE(pr.display_name, '') + FROM posts p + JOIN users u ON u.id = p.author_id + LEFT JOIN profiles pr ON pr.id = p.author_id + WHERE p.id = $1::uuid + `, postID).Scan(&authorID, &authorEmail, &displayName) + _, err := h.pool.Exec(ctx, `UPDATE posts SET deleted_at = NOW(), status = 'removed' WHERE id = $1::uuid`, postID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete post"}) @@ -758,6 +856,29 @@ func (h *AdminHandler) DeletePost(c *gin.Context) { VALUES ($1, 'admin_delete_post', 'post', $2::uuid, '{}') `, adminUUID, postID) + // Record a strike on the author + if authorID != uuid.Nil { + reason := "Post deleted by moderation team" + h.pool.Exec(ctx, ` + INSERT INTO content_strikes (user_id, category, content_snippet, created_at) + VALUES ($1, 'moderation', $2, NOW()) + `, authorID, reason) + + var strikeCount int + h.pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM content_strikes + WHERE user_id = $1 AND created_at > NOW() - INTERVAL '30 days' + `, authorID).Scan(&strikeCount) + + if h.emailService != nil && authorEmail != "" { + go func() { + if err := h.emailService.SendPostRemovalEmail(authorEmail, displayName, reason, strikeCount); err != nil { + log.Error().Err(err).Str("user", authorID.String()).Msg("Failed to send post removal email") + } + }() + } + } + c.JSON(http.StatusOK, gin.H{"message": "Post deleted"}) } diff --git a/go-backend/internal/services/email_service.go b/go-backend/internal/services/email_service.go index 12d0db2..d8270e6 100644 --- a/go-backend/internal/services/email_service.go +++ b/go-backend/internal/services/email_service.go @@ -260,6 +260,86 @@ func (s *EmailService) sendViaSendPulse(toEmail, toName, subject, htmlBody, text return nil } +func (s *EmailService) SendBanNotificationEmail(toEmail, toName, reason string) error { + subject := "Your Sojorn account has been suspended" + + title := "Account Suspended" + header := "Your account has been suspended" + content := fmt.Sprintf(` +
Hi %s,
+After a review of your recent activity, your Sojorn account has been permanently suspended for violating our Community Guidelines.
++ Reason: %s +
+What this means:
+If you believe this action was taken in error, you can submit an appeal. Our team will review your case within 5 business days.
+ `, toName, reason) + + appealURL := "mailto:appeals@sojorn.net?subject=Account%20Appeal" + footer := `You can also reply directly to this email to submit your appeal.
` + + htmlBody := s.buildHTMLEmail(title, header, content, appealURL, "Submit an Appeal", footer) + textBody := fmt.Sprintf("Hi %s,\n\nYour Sojorn account has been permanently suspended.\n\nReason: %s\n\nIf you believe this was in error, reply to this email or contact appeals@sojorn.net.\n\n— The Sojorn Team", toName, reason) + + return s.sendEmail(toEmail, toName, subject, htmlBody, textBody) +} + +func (s *EmailService) SendSuspensionNotificationEmail(toEmail, toName, reason, duration string) error { + subject := "Your Sojorn account has been temporarily suspended" + + title := "Account Temporarily Suspended" + header := "Your account has been temporarily suspended" + content := fmt.Sprintf(` +Hi %s,
+Your Sojorn account has been temporarily suspended for %s due to a violation of our Community Guidelines.
++ Reason: %s +
+Your account will be automatically restored after the suspension period. Further violations may result in a permanent ban.
+ `, toName, duration, reason) + + footer := `` + htmlBody := s.buildHTMLEmail(title, header, content, "https://sojorn.net/terms", "Review Community Guidelines", footer) + textBody := fmt.Sprintf("Hi %s,\n\nYour Sojorn account has been temporarily suspended for %s.\n\nReason: %s\n\nYour account will be restored after the suspension period.\n\n— The Sojorn Team", toName, duration, reason) + + return s.sendEmail(toEmail, toName, subject, htmlBody, textBody) +} + +func (s *EmailService) SendPostRemovalEmail(toEmail, toName, reason string, strikeCount int) error { + subject := "A post on your Sojorn account was removed" + + title := "Content Removed" + header := "Your post has been removed" + + strikeWarning := "" + if strikeCount >= 5 { + strikeWarning = `Warning: You are close to a permanent suspension. Please review our Community Guidelines carefully.
` + } else if strikeCount >= 3 { + strikeWarning = `Continued violations may result in a temporary or permanent suspension of your account.
` + } + + content := fmt.Sprintf(` +Hi %s,
+One of your posts has been removed by our moderation team for violating our Community Guidelines.
++ Reason: %s +
+This is strike %d on your account. Strikes are tracked over a 30-day rolling window.
+ %s +If you believe this was in error, you can reply to this email.
+ `, toName, reason, strikeCount, strikeWarning) + + footer := `` + htmlBody := s.buildHTMLEmail(title, header, content, "https://sojorn.net/terms", "Review Community Guidelines", footer) + textBody := fmt.Sprintf("Hi %s,\n\nOne of your posts has been removed.\n\nReason: %s\nStrike %d on your account.\n\nPlease review our Community Guidelines.\n\n— The Sojorn Team", toName, reason, strikeCount) + + 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