Admin moderation: ban emails, post removal emails with strikes, appeal flow
This commit is contained in:
parent
f4701b0d24
commit
7e721aea21
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue