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(
|
mediaHandler := handlers.NewMediaHandler(
|
||||||
s3Client,
|
s3Client,
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ type AdminHandler struct {
|
||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
moderationService *services.ModerationService
|
moderationService *services.ModerationService
|
||||||
appealService *services.AppealService
|
appealService *services.AppealService
|
||||||
|
emailService *services.EmailService
|
||||||
jwtSecret string
|
jwtSecret string
|
||||||
turnstileSecret string
|
turnstileSecret string
|
||||||
s3Client *s3.Client
|
s3Client *s3.Client
|
||||||
|
|
@ -33,11 +34,12 @@ type AdminHandler struct {
|
||||||
vidDomain string
|
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{
|
return &AdminHandler{
|
||||||
pool: pool,
|
pool: pool,
|
||||||
moderationService: moderationService,
|
moderationService: moderationService,
|
||||||
appealService: appealService,
|
appealService: appealService,
|
||||||
|
emailService: emailService,
|
||||||
jwtSecret: jwtSecret,
|
jwtSecret: jwtSecret,
|
||||||
turnstileSecret: turnstileSecret,
|
turnstileSecret: turnstileSecret,
|
||||||
s3Client: s3Client,
|
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)
|
h.pool.QueryRow(ctx, `SELECT status FROM users WHERE id = $1::uuid`, targetUserID).Scan(&oldStatus)
|
||||||
|
|
||||||
// Update user status
|
// Update user status
|
||||||
_, err := h.pool.Exec(ctx, `UPDATE users SET status = $1 WHERE id = $2::uuid`, req.Status, targetUserID)
|
if req.Status == "banned" {
|
||||||
if err != nil {
|
_, err := h.pool.Exec(ctx, `UPDATE users SET status = 'banned' WHERE id = $1::uuid`, targetUserID)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update user status"})
|
if err != nil {
|
||||||
return
|
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
|
// Log status change
|
||||||
|
|
@ -473,6 +500,25 @@ func (h *AdminHandler) UpdateUserStatus(c *gin.Context) {
|
||||||
VALUES ($1::uuid, $2, $3, $4, $5)
|
VALUES ($1::uuid, $2, $3, $4, $5)
|
||||||
`, targetUserID, oldStatus, req.Status, req.Reason, adminUUID)
|
`, 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})
|
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)
|
VALUES ($1, $2, 'post', $3::uuid, $4)
|
||||||
`, adminUUID, "post_status_change", postID, fmt.Sprintf(`{"status":"%s","reason":"%s"}`, req.Status, req.Reason))
|
`, 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})
|
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")
|
adminID, _ := c.Get("user_id")
|
||||||
postID := c.Param("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)
|
_, err := h.pool.Exec(ctx, `UPDATE posts SET deleted_at = NOW(), status = 'removed' WHERE id = $1::uuid`, postID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete post"})
|
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, '{}')
|
VALUES ($1, 'admin_delete_post', 'post', $2::uuid, '{}')
|
||||||
`, adminUUID, postID)
|
`, 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"})
|
c.JSON(http.StatusOK, gin.H{"message": "Post deleted"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -260,6 +260,86 @@ func (s *EmailService) sendViaSendPulse(toEmail, toName, subject, htmlBody, text
|
||||||
return nil
|
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) {
|
func (s *EmailService) AddSubscriber(email, name string) {
|
||||||
// SendPulse Addressbook API implementation omitted for brevity, focusing on email first
|
// SendPulse Addressbook API implementation omitted for brevity, focusing on email first
|
||||||
// Endpoint: POST /addressbooks/{id}/emails
|
// Endpoint: POST /addressbooks/{id}/emails
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue