Admin moderation: ban emails, post removal emails with strikes, appeal flow

This commit is contained in:
Patrick Britton 2026-02-06 12:14:13 -06:00
parent f4701b0d24
commit 7e721aea21
3 changed files with 207 additions and 6 deletions

View file

@ -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,

View file

@ -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,11 +462,36 @@ 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 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
adminUUID, _ := uuid.Parse(adminID.(string))
@ -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"})
}

View file

@ -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(`
<p>Hi %s,</p>
<p>After a review of your recent activity, your Sojorn account has been <strong>permanently suspended</strong> for violating our <a href="https://sojorn.net/terms" style="color: #4338CA;">Community Guidelines</a>.</p>
<p style="background: #FEF2F2; border-left: 4px solid #EF4444; padding: 12px 16px; border-radius: 4px; margin: 16px 0;">
<strong>Reason:</strong> %s
</p>
<p>What this means:</p>
<ul style="text-align: left; color: #4B5563;">
<li>You will no longer be able to log in or post content</li>
<li>Your profile will not be visible to other users</li>
</ul>
<p>If you believe this action was taken in error, you can submit an appeal. Our team will review your case within 5 business days.</p>
`, toName, reason)
appealURL := "mailto:appeals@sojorn.net?subject=Account%20Appeal"
footer := `<p style="color: #9CA3AF; font-size: 12px; margin-top: 16px;">You can also reply directly to this email to submit your appeal.</p>`
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(`
<p>Hi %s,</p>
<p>Your Sojorn account has been <strong>temporarily suspended for %s</strong> due to a violation of our <a href="https://sojorn.net/terms" style="color: #4338CA;">Community Guidelines</a>.</p>
<p style="background: #FFFBEB; border-left: 4px solid #F59E0B; padding: 12px 16px; border-radius: 4px; margin: 16px 0;">
<strong>Reason:</strong> %s
</p>
<p>Your account will be automatically restored after the suspension period. Further violations may result in a permanent ban.</p>
`, 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 = `<p style="color: #EF4444; font-weight: 600;">Warning: You are close to a permanent suspension. Please review our Community Guidelines carefully.</p>`
} else if strikeCount >= 3 {
strikeWarning = `<p style="color: #F59E0B; font-weight: 600;">Continued violations may result in a temporary or permanent suspension of your account.</p>`
}
content := fmt.Sprintf(`
<p>Hi %s,</p>
<p>One of your posts has been removed by our moderation team for violating our <a href="https://sojorn.net/terms" style="color: #4338CA;">Community Guidelines</a>.</p>
<p style="background: #FEF2F2; border-left: 4px solid #EF4444; padding: 12px 16px; border-radius: 4px; margin: 16px 0;">
<strong>Reason:</strong> %s
</p>
<p>This is <strong>strike %d</strong> on your account. Strikes are tracked over a 30-day rolling window.</p>
%s
<p>If you believe this was in error, you can reply to this email.</p>
`, 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