# Moderation Skill

> **Purpose:** Content moderation including bans, warnings, mutes, reports, and content filtering
> **When to use:** Any task involving moderation features
> **Related skills:** wordpress-plugin-foundation-skill.md (always applies)

---

## Quick Reference

```php
// Ban user
Bans::ban($user_id, Bans::DURATION_1_WEEK, 'Spam', 'Internal notes', get_current_user_id());

// Issue warning
Warnings::issue($user_id, Warnings::TYPE_WARNING, 'Off-topic post', null, $post_id, get_current_user_id());

// Issue mute
Warnings::issue($user_id, Warnings::TYPE_MUTE, 'Spam behavior', 86400, null, get_current_user_id());

// Check if user can post
if (!Warnings::canPost($user_id)) {
    wp_send_json_error(['message' => Warnings::getMuteMessage($user_id)], 403);
}

// Validate content
$creator = new PostCreator();
try {
    $creator->checkExternalLinks($content);
    $creator->checkWordFilter($content);
} catch (\InvalidArgumentException $e) {
    wp_send_json_error(['message' => $e->getMessage()], 400);
}
```

---

## Ban System

### Ban Durations

```php
public const DURATION_1_DAY = 86400;
public const DURATION_1_WEEK = 604800;
public const DURATION_1_MONTH = 2592000;
public const DURATION_PERMANENT = 'permanent';
```

### Ban User

```php
public static function ban(
    int $userId,
    $duration,
    string $reason = '',
    string $notes = '',
    ?int $bannedBy = null
): bool {
    // Cannot ban administrators
    if (user_can($userId, 'administrator')) {
        return false;
    }
    
    // Calculate expiry
    $expires = null;
    if ($duration !== 'permanent' && is_numeric($duration)) {
        $expires = time() + (int) $duration;
    }
    
    // Store ban info in user meta
    update_user_meta($userId, 'presszone_forum_banned', 1);
    update_user_meta($userId, 'presszone_forum_ban_expires', $expires);
    update_user_meta($userId, 'presszone_forum_ban_reason', $reason);
    update_user_meta($userId, 'presszone_forum_banned_by', $bannedBy ?? get_current_user_id());
    update_user_meta($userId, 'presszone_forum_banned_at', current_time('mysql'));
    update_user_meta($userId, 'presszone_forum_ban_notes', $notes);
    
    // Fire action for logging
    do_action('presszone_forum_user_banned', $userId, $expires, $reason, $notes, $bannedBy);
    
    // Send email notification
    self::sendBanEmail($userId, $reason, $expires);
    
    return true;
}
```

### Check if Banned

```php
public static function isBanned(int $userId): bool
{
    $banned = get_user_meta($userId, 'presszone_forum_banned', true);
    
    if (!$banned) {
        return false;
    }
    
    // Check if ban expired
    $expires = get_user_meta($userId, 'presszone_forum_ban_expires', true);
    
    if ($expires && time() > $expires) {
        // Ban expired - auto-unban
        self::unban($userId);
        return false;
    }
    
    return true;
}
```

### Unban User

```php
public static function unban(int $userId): bool
{
    delete_user_meta($userId, 'presszone_forum_banned');
    delete_user_meta($userId, 'presszone_forum_ban_expires');
    delete_user_meta($userId, 'presszone_forum_ban_reason');
    delete_user_meta($userId, 'presszone_forum_banned_by');
    delete_user_meta($userId, 'presszone_forum_banned_at');
    delete_user_meta($userId, 'presszone_forum_ban_notes');
    
    do_action('presszone_forum_user_unbanned', $userId);
    
    return true;
}
```

---

## Warning System

### Warning Types

```php
public const TYPE_WARNING = 'warning';  // Note only
public const TYPE_MUTE = 'mute';        // Cannot post
public const TYPE_BAN = 'ban';          // Full ban
```

### Issue Warning/Mute

```php
public static function issue(
    int $userId,
    string $type,
    string $reason,
    ?int $duration,
    ?int $postId,
    ?int $issuedBy,
    ?string $moderatorNotes = null
): int|false {
    global $wpdb;
    
    // Calculate expiry
    $expiresAt = null;
    if ($duration) {
        $expiresAt = gmdate('Y-m-d H:i:s', time() + $duration);
    }
    
    $result = $wpdb->insert(
        $wpdb->prefix . 'presszone_forum_warnings',
        [
            'user_id' => $userId,
            'issued_by' => $issuedBy ?? get_current_user_id(),
            'warning_type' => $type,
            'reason' => $reason,
            'moderator_notes' => $moderatorNotes,
            'post_id' => $postId,
            'duration' => $duration,
            'expires_at' => $expiresAt,
            'is_active' => 1,
            'created_at' => current_time('mysql'),
        ],
        ['%d', '%d', '%s', '%s', '%s', '%d', '%d', '%s', '%d', '%s']
    );
    
    if (!$result) {
        return false;
    }
    
    $warningId = $wpdb->insert_id;
    
    // Fire action
    do_action('presszone_forum_warning_issued', $warningId, $userId, $type, $reason);
    
    // Send email
    self::sendWarningEmail($userId, $type, $reason, $expiresAt);
    
    return $warningId;
}
```

### Check if User Can Post

```php
public static function canPost(int $userId): bool
{
    $activeMute = self::getActiveMute($userId);
    
    if (!$activeMute) {
        return true;
    }
    
    // Check if mute expired
    if ($activeMute['expires_at']) {
        $expires = strtotime($activeMute['expires_at']);
        if (time() > $expires) {
            // Mute expired
            self::clear($activeMute['warning_id']);
            return true;
        }
    }
    
    return false;
}

public static function getActiveMute(int $userId): ?array
{
    global $wpdb;
    
    $mute = $wpdb->get_row($wpdb->prepare(
        "SELECT * FROM {$wpdb->prefix}presszone_forum_warnings
         WHERE user_id = %d 
         AND warning_type = 'mute'
         AND is_active = 1
         ORDER BY created_at DESC
         LIMIT 1",
        $userId
    ), ARRAY_A);
    
    return $mute ?: null;
}
```

### Escalation Logic

```php
public static function getSuggestedAction(int $userId): array
{
    $warnings = self::getWarnings($userId, true);
    $warningCount = count(array_filter($warnings, fn($w) => $w['warning_type'] === 'warning'));
    $muteCount = count(array_filter($warnings, fn($w) => $w['warning_type'] === 'mute'));
    $banCount = count(array_filter($warnings, fn($w) => $w['warning_type'] === 'ban'));
    
    $thresholds = [
        'mute' => (int) get_option('presszone_forum_warning_threshold_mute', 3),
        'ban' => (int) get_option('presszone_forum_warning_threshold_ban', 5),
        'permanent' => (int) get_option('presszone_forum_permanent_ban_threshold', 3),
    ];
    
    // Suggest permanent ban
    if ($banCount >= $thresholds['permanent']) {
        return [
            'type' => 'ban',
            'duration' => 'permanent',
            'rationale' => sprintf('User has %d previous bans', $banCount),
        ];
    }
    
    // Suggest temporary ban
    if ($warningCount >= $thresholds['ban']) {
        return [
            'type' => 'ban',
            'duration' => Bans::DURATION_1_WEEK,
            'rationale' => sprintf('User has %d warnings', $warningCount),
        ];
    }
    
    // Suggest mute
    if ($warningCount >= $thresholds['mute']) {
        return [
            'type' => 'mute',
            'duration' => 86400, // 24 hours
            'rationale' => sprintf('User has %d warnings', $warningCount),
        ];
    }
    
    // Suggest warning
    return [
        'type' => 'warning',
        'duration' => null,
        'rationale' => 'First or minor infraction',
    ];
}
```

---

## Content Validation

### NEVER Duplicate Validation Logic

**ALWAYS use PostCreator methods. NEVER inline validation.**

```php
// CORRECT - Reuse centralized methods
$creator = new PostCreator();
try {
    $creator->checkExternalLinks($content);
    $creator->checkWordFilter($content);
} catch (\InvalidArgumentException $e) {
    return new WP_Error('validation_error', $e->getMessage());
}

// WRONG - Duplicating validation inline
if (preg_match('#https?://#', $content)) {  // NO! Use checkExternalLinks()
    return new WP_Error('external_links', 'External links not allowed');
}
```

### External Links Check

```php
public function checkExternalLinks(string $content): void
{
    $allowExternalLinks = get_option('presszone_forum_allow_external_links', '1');
    
    if ($allowExternalLinks === '1') {
        return;
    }
    
    // Check for external URLs
    $pattern = '#(?:https?://|www\.)[^\s<"\')\]]+#i';
    
    if (preg_match($pattern, $content)) {
        throw new \InvalidArgumentException(
            __('External links are not allowed', 'forum-press-zone')
        );
    }
}
```

### Word Filter Check

```php
public function checkWordFilter(string $content): void
{
    $wordFilter = get_option('presszone_forum_word_filter', '');
    
    if (empty($wordFilter)) {
        return;
    }
    
    $bannedWords = array_map('trim', explode(',', $wordFilter));
    
    foreach ($bannedWords as $word) {
        if (empty($word)) {
            continue;
        }
        
        // Support wildcards
        $pattern = $this->wordToPattern($word);
        
        if (preg_match($pattern, $content)) {
            throw new \InvalidArgumentException(
                __('Your message contains prohibited content', 'forum-press-zone')
            );
        }
    }
}

private function wordToPattern(string $word): string
{
    // Escape special regex characters
    $word = preg_quote($word, '/');
    
    // Replace wildcards
    $word = str_replace('\*', '.*?', $word);
    
    // Word boundary if no wildcards at edges
    $start = strpos($word, '.*?') === 0 ? '' : '\b';
    $end = strrpos($word, '.*?') === strlen($word) - 4 ? '' : '\b';
    
    return "/{$start}{$word}{$end}/i";
}
```

---

## Report System

### Report Statuses

```php
private const STATUS_OPEN = 'open';
private const STATUS_RESOLVED = 'resolved';
private const STATUS_REJECTED = 'rejected';
```

### Submit Report

```php
public function submitReport(
    string $contentType,
    int $contentId,
    int $reporterId,
    string $reason
): int {
    global $wpdb;
    
    $wpdb->insert(
        $wpdb->prefix . 'presszone_forum_reports',
        [
            'content_type' => $contentType,
            'content_id' => $contentId,
            'reporter_id' => $reporterId,
            'reason' => $reason,
            'status' => self::STATUS_OPEN,
            'created_at' => current_time('mysql'),
        ],
        ['%s', '%d', '%d', '%s', '%s', '%s']
    );
    
    return $wpdb->insert_id;
}
```

### Get Reports (with Access Control)

```php
public function getReports(int $page = 1, int $perPage = 50): array
{
    global $wpdb;
    
    $offset = ($page - 1) * $perPage;
    
    // Filter by moderator access
    $nodeFilter = '';
    if (!Roles::canModerateAll()) {
        $moderators = new Moderators();
        $nodeIds = $moderators->getAllModeratedNodes(get_current_user_id());
        
        if (empty($nodeIds)) {
            return ['reports' => [], 'total' => 0];
        }
        
        $placeholders = implode(',', array_fill(0, count($nodeIds), '%d'));
        $nodeFilter = $wpdb->prepare(
            " AND p.node_id IN ($placeholders)",
            ...$nodeIds
        );
    }
    
    $reports = $wpdb->get_results($wpdb->prepare(
        "SELECT 
            r.*,
            u.display_name as reporter_name,
            p.content as post_content,
            p.node_id
         FROM {$wpdb->prefix}presszone_forum_reports r
         LEFT JOIN {$wpdb->users} u ON r.reporter_id = u.ID
         LEFT JOIN {$wpdb->prefix}presszone_forum_posts p ON r.content_id = p.post_id
         WHERE r.status = 'open' {$nodeFilter}
         ORDER BY r.created_at DESC
         LIMIT %d OFFSET %d",
        $perPage,
        $offset
    ), ARRAY_A);
    
    return [
        'reports' => $reports,
        'total' => $this->getReportCount(),
    ];
}
```

### Resolve Report

```php
public function resolveReport(int $reportId, string $action, ?string $note = null): bool
{
    global $wpdb;
    
    $report = $this->getReport($reportId);
    if (!$report) {
        return false;
    }
    
    // Perform action
    switch ($action) {
        case 'delete-content':
            $this->softDeleteContent($report['content_type'], $report['content_id']);
            break;
        case 'delete-thread':
            $this->softDeleteThread($report['content_id']);
            break;
        case 'dismiss':
            // No action on content
            break;
    }
    
    // Update report status
    $wpdb->update(
        $wpdb->prefix . 'presszone_forum_reports',
        [
            'status' => $action === 'dismiss' ? 'rejected' : 'resolved',
            'resolve_date' => current_time('mysql'),
            'resolver_id' => get_current_user_id(),
            'resolution_note' => $note,
        ],
        ['report_id' => $reportId],
        ['%s', '%s', '%d', '%s'],
        ['%d']
    );
    
    return true;
}
```

---

## Soft Delete Pattern

### Soft Delete Content

```php
private function softDeleteContent(string $contentType, int $contentId): bool
{
    global $wpdb;
    
    $table = $contentType === 'post' 
        ? $wpdb->prefix . 'presszone_forum_posts'
        : $wpdb->prefix . 'presszone_forum_threads';
    
    $idColumn = $contentType === 'post' ? 'post_id' : 'thread_id';
    
    return (bool) $wpdb->update(
        $table,
        [
            'is_soft_deleted' => 1,
            'deleted_at' => current_time('mysql'),
            'deleted_by' => get_current_user_id(),
        ],
        [$idColumn => $contentId],
        ['%d', '%s', '%d'],
        ['%d']
    );
}
```

---

## Nested BBCode Parsing

### Use Negative Lookahead

```php
// CORRECT - Matches innermost quotes first
$pattern = '#\[quote="([^"]+)"\]((?:(?!\[quote).)*?)\[/quote\]#is';

// Process innermost first
while (preg_match($pattern, $content, $matches)) {
    $author = $matches[1];
    $quoteContent = $matches[2];
    
    $replacement = $this->renderQuote($author, $quoteContent);
    $content = preg_replace($pattern, $replacement, $content, 1);
}

// WRONG - Breaks on nested quotes
$pattern = '#\[quote="([^"]+)"\](.*?)\[/quote\]#is';
```

---

## Administrator Protection

### ALWAYS Protect Administrators

```php
// ALWAYS check before moderation actions
if (user_can($target_user_id, 'administrator')) {
    return new WP_Error(
        'forbidden',
        __('Cannot moderate administrators', 'forum-press-zone'),
        ['status' => 403]
    );
}
```

---

## Audit Logging

### Log All Moderation Actions

```php
// Fire actions for audit trail
do_action('presszone_forum_user_banned', $userId, $expires, $reason, $notes, $bannedBy);
do_action('presszone_forum_user_unbanned', $userId);
do_action('presszone_forum_warning_issued', $warningId, $userId, $type, $reason);
do_action('presszone_forum_content_deleted', $contentId, $contentType, $reason, $moderatorId);

// Log to error_log (no PII)
error_log(sprintf(
    'Moderation: User %d banned by user %d. Duration: %s',
    $userId,
    $bannedBy,
    $duration
));
```

---

## Common Mistakes

| Mistake | Fix |
|---------|-----|
| Duplicating validation logic | Use `PostCreator->checkExternalLinks()` and `checkWordFilter()` |
| Using `.*?` for nested BBCode | Use `(?:(?!\[quote).)*?` negative lookahead |
| Not checking admin protection | Always verify `!user_can($userId, 'administrator')` |
| Forgetting soft delete fields | Set `is_soft_deleted`, `deleted_at`, `deleted_by` |
| Missing audit hooks | Fire `do_action()` for all moderation actions |
| Not filtering reports by access | Check moderator's node access |
| Hardcoded durations | Use duration constants |
| Logging personal data | Log only IDs and actions |

---

## Integration with Other Skills

- **wordpress-plugin-foundation-skill.md** - Security and compliance (always applies)
- **php-skill.md** - PHP patterns
- **sql-skill.md** - Database queries
- **rest-api-skill.md** - Moderation API endpoints
