================================================================================
TAG API — NSFW MODERATION: COMPLETE SYSTEM STUDY
================================================================================
Last updated: 2026-06-26
Covers: public_posts, tag_vibes, moments, tag_space_posts


================================================================================
1. ARCHITECTURE OVERVIEW
================================================================================

The NSFW system has three layers:

  Layer 1 — PHP API (synchronous)
    Creates the content row with scan_status = 'pending', immediately upgrades
    it to 'clean' via NsfwService::markClean(), queues media paths into
    nsfw_scan_queue, then returns 201.

  Layer 2 — Python worker (asynchronous, every 5 minutes)
    Reads up to 30 rows from nsfw_scan_queue, runs each file through NudeNet
    (local AI, no external API), updates scan_status + scan_confidence on the
    content table, sends FCM push to the author if flagged, marks the queue
    row done.

  Layer 3 — Admin review (manual)
    Author submits a review request via API. Admin approves (restores to clean)
    or rejects (stays flagged). Each action triggers an FCM push to the author.

Key design decision: "optimistic publish" — content is immediately visible to
everyone in clean state; moderation corrects it asynchronously. Maximum lag
between upload and scan result is 5 minutes (one cron tick).


================================================================================
2. CONTENT TYPES IN SCOPE
================================================================================

  content_type   DB table              Owner column            Controller
  -----------    ------------------    --------------------    ----------------------
  post           public_posts          user_id                 PublicPostController
  vibe           tag_vibes             user_id                 TagVibeController
  moment         moments               user_id                 MomentController
  space_post     tag_space_posts       via tag_space_sessions  TagSpacePostController

All four tables carry the same two columns:
  scan_status      ENUM('pending','clean','sensitive','removed')  DEFAULT 'pending'
  scan_confidence  FLOAT NULL

space_post ownership is indirect: the post belongs to a tag_space_session row,
and the session belongs to a user_id. Every part of the system that resolves
ownership for space_posts must join through tag_space_sessions.


================================================================================
3. SCAN STATUSES AND WHAT THEY MEAN
================================================================================

  pending    Row was just inserted, not yet classified.
             API: immediately upgraded to 'clean' by NsfwService::markClean().
             Feed: only visible to the author themselves.

  clean      Scanner confirmed no NSFW content (or no media to scan).
             Feed: visible to everyone normally.

  sensitive  Scanner detected borderline content above SENSITIVE threshold.
             Feed: included in responses with is_sensitive = true.
             Frontend should blur/overlay the media.
             Author receives push notification. Can request review.

  removed    Scanner detected explicit content above REMOVE threshold.
             Feed: excluded from all queries, including the author's own.
             Author receives push notification. Can request review.


================================================================================
4. DATABASE TABLES
================================================================================

nsfw_scan_queue
  id            BIGINT UNSIGNED AUTO_INCREMENT
  content_type  ENUM('post','vibe','moment','space_post')
  content_id    BIGINT UNSIGNED
  file_paths    LONGTEXT  — JSON array of absolute server file paths
  processed_at  DATETIME NULL  — NULL = pending, set when worker finishes
  created_at    TIMESTAMP

  One row per content item (not per file). All files for one item are stored
  in the single file_paths JSON array and processed together.

content_review_requests
  id             BIGINT UNSIGNED AUTO_INCREMENT
  content_type   ENUM('post','vibe','moment','space_post')
  content_id     BIGINT UNSIGNED
  user_id        BIGINT UNSIGNED
  reason         TEXT NULL
  status         ENUM('pending','approved','rejected')  DEFAULT 'pending'
  reviewer_note  TEXT NULL
  created_at     TIMESTAMP
  updated_at     TIMESTAMP

  UNIQUE KEY (content_type, content_id, user_id) — one active request per
  user per piece of content. ON DUPLICATE KEY UPDATE resets status to pending,
  so re-submitting after rejection is allowed.


================================================================================
5. CREATION FLOW (SAME FOR ALL CONTENT TYPES)
================================================================================

  1. Controller receives the POST request.
  2. Content row is INSERTed with scan_status = 'pending'.
  3. Media files are uploaded via UploadService; each returns:
       file_path   — absolute server path (scanner uses this)
       file_url    — public URL (clients use this)
       media_type  — 'image' | 'video' | 'audio' | 'document'
       thumbnail_path — set for videos (UploadService generates a thumbnail)
  4. NsfwService::enqueue(contentType, contentId, uploads) is called:
       a. Calls markClean() → UPDATE {table} SET scan_status = 'clean' WHERE id = ?
          Content is now publicly visible.
       b. Collects scannable files:
            images: file_path
            videos: thumbnail_path (the uploaded still frame)
            audio, documents: skipped (not visual content)
       c. If scannable files exist: INSERT into nsfw_scan_queue.
          If no scannable files: queue is skipped; content stays clean permanently.
  5. Controller returns 201.

  Special case — TagVibe remasters (TagVibeController::remaster()):
    Copies media from the original vibe. Does NOT call NsfwService::enqueue().
    The original was already scanned, so the copy inherits that safety level.
    [BUG] The remasted vibe's scan_status stays 'pending' forever because
    markClean() is never called for it. This means the remaster is only visible
    to the author. See Section 9 for full bug list.

  Special case — share from TagSpace (PublicPostController::shareFromTagSpace()):
    Blocks sharing if scan_status is 'sensitive' or 'pending'.
    Calls NsfwService::markClean() directly — skips the queue entirely.
    Comment in code: "Source was verified clean — skip the queue."
    Note: 'removed' posts are blocked by TagSpacePost::findById() which already
    excludes them (scan_status != 'removed'), so the share endpoint gets a 404
    for removed posts before reaching the scan check.


================================================================================
6. BACKGROUND SCANNER
================================================================================

File:    scripts/nsfw_scanner.py
Python:  /opt/hc_python/bin/python3.12
Model:   NudeNet (local AI, runs offline, no per-scan cost)
Log:     storage/logs/nsfw_scanner.log

Cron (every 5 minutes):
  0,5,10,15,20,25,30,35,40,45,50,55 * * * * \
    /opt/hc_python/bin/python3.12 \
    /home/nsamhihp/tag.nsamaandcompany.com/tag_api/scripts/nsfw_scanner.py \
    >> /home/nsamhihp/tag.nsamaandcompany.com/tag_api/storage/logs/nsfw_scanner.log 2>&1

Performance guard: OMP_NUM_THREADS=1, OPENBLAS_NUM_THREADS=1 to prevent
NudeNet from spawning dozens of threads on shared hosting.

-- Detection thresholds --

  REMOVE_RULES — triggers scan_status = 'removed'
    EXPOSED_GENITALIA_F   >= 0.70
    EXPOSED_GENITALIA_M   >= 0.70
    EXPOSED_ANUS          >= 0.70
    EXPOSED_BREAST_F      >= 0.80

  SENSITIVE_RULES — triggers scan_status = 'sensitive' (only if not 'removed')
    EXPOSED_BREAST_F      >= 0.40   (lower threshold than REMOVE)
    EXPOSED_BUTTOCKS      >= 0.60

  When multiple images exist in one queue row, the worst result across all
  images wins. As soon as one image hits 'removed', scanning stops early.

-- Worker loop --

  1. SELECT up to 30 unprocessed rows (processed_at IS NULL), oldest first.
  2. If none, log "No pending items." and exit.
  3. For each row:
       a. Parse file_paths JSON.
       b. Run classify_image() on each path.
       c. Aggregate: worst_status(all_results) → final_status.
       d. UPDATE {table} SET scan_status = final_status,
                              scan_confidence = max_conf WHERE id = content_id
       e. COMMIT.
       f. If final_status in ('sensitive', 'removed'):
            Resolve user_id:
              post/vibe/moment → SELECT user_id FROM {table} WHERE id = content_id
              space_post       → SELECT tss.user_id FROM tag_space_posts tsp
                                  INNER JOIN tag_space_sessions tss ON tss.id = tsp.tag_space_session_id
                                  WHERE tsp.id = content_id
            Fetch push tokens: SELECT push_token FROM user_devices WHERE user_id = ?
            Send FCM push via v1 API (see Section 7).
       g. UPDATE nsfw_scan_queue SET processed_at = NOW() WHERE id = queue_id
       h. COMMIT.
  4. Log "Done."

-- classify_image() --

  Calls NudeDetector.detect(path). Returns ('clean'|'sensitive'|'removed', confidence).
  File-not-found → returns ('clean', 0.0) — silently skips missing files.
  Detection exception → returns ('clean', 0.0) — fail-open (safe side).


================================================================================
7. FCM PUSH NOTIFICATIONS
================================================================================

-- PHP path (FirebaseService::sendFcmPush) --
  Used by ContentReviewController for review decision notifications.
  Authenticates via Google OAuth2 JWT assertion flow.
  Token is cached in-process (static property) for 1 hour minus 60s buffer.
  Credentials file: path from FIREBASE_CREDENTIALS env var.

-- Python path (send_flag_push in nsfw_scanner.py) --
  Used by the scanner for flagging notifications.
  Authenticates via google-auth service_account library.
  Token cached in module-level dict for ~1 hour (3500s buffer).
  Credentials file: /home/nsamhihp/firebase-service-account.json (hardcoded).

Both paths hit: https://fcm.googleapis.com/v1/projects/{project_id}/messages:send

-- Payloads by event --

  content_flagged (scanner, sensitive content):
    title:            "Post Flagged"
    body:             "Your {label} has been flagged as sensitive content.
                       You can request a review if you think this is a mistake."
    data.type:        "content_flagged"
    data.content_type: e.g. "space_post"
    data.content_id:   e.g. "42"
    android color:    #FF8C00 (orange)
    android channel:  "moderation"

  content_removed (scanner, explicit content):
    title:            "Post Removed"
    body:             "Your {label} was removed for violating our community
                       guidelines. You can request a review if you think this
                       is a mistake."
    data.type:        "content_removed"
    data.content_type, data.content_id: same as above
    android color:    #DC143C (crimson)
    android channel:  "moderation"

  review_approved (admin approves, PHP):
    title:            "Content Restored"
    body:             "Your {label} has been reviewed and restored.
                       Thank you for your patience."
    data.type:        "review_approved"
    data.content_type, data.content_id: from review request row
    android color:    #DC143C  ← [BUG] always red; PHP doesn't support per-push color

  review_rejected (admin rejects, PHP):
    title:            "Review Decision"
    body:             "We reviewed your {label} and found it does violate our
                       community guidelines. It will remain {removed|flagged}."
    data.type:        "review_rejected"
    data.content_type, data.content_id: from review request row
    android color:    #DC143C  ← same note

  Both push platforms: Android priority = high, APNS priority = 10.


================================================================================
8. API ENDPOINTS
================================================================================

All endpoints require: Authorization: Bearer {jwt}

-- User endpoints --

POST /api/v1/content/{type}/{id}/request-review
  Controller: ContentReviewController::request()
  type must be: post | vibe | moment | space_post
  Body: { "reason": "optional explanation" }

  Rules:
  - Caller must be the content owner (for space_post, owner = session creator).
  - scan_status must be 'sensitive' or 'removed' (clean/pending → 422).
  - If a pending review request already exists → 422 "already submitted".
  - Re-submitting after a rejected request → resets status to pending (upsert).

  Success 201:
    { "data": { "content_type": "space_post", "content_id": 42 } }

-- Admin endpoints (gated by ADMIN_EMAIL in .env) --

GET  /api/v1/admin/review-requests
  Returns up to 50 pending requests, oldest first (all content types).

POST /api/v1/admin/review-requests/{id}/approve
  Body: { "note": "optional note" }
  Actions:
    UPDATE {table} SET scan_status = 'clean', scan_confidence = NULL
    Sets review request status = 'approved'
    Sends "Content Restored" push to author

POST /api/v1/admin/review-requests/{id}/reject
  Body: { "note": "optional note" }
  Actions:
    Leaves content scan_status unchanged
    Sets review request status = 'rejected'
    Sends "Review Decision" push to author


================================================================================
9. FEED VISIBILITY RULES BY CONTENT TYPE
================================================================================

  public_posts feed (PublicPost::feed(), feedFillTrending(), feedFillSeen()):
    WHERE scan_status NOT IN ('pending', 'removed')   ← pending AND removed excluded
    Author-profile view (feedByAuthor()):
    WHERE scan_status != 'removed'
      AND (scan_status != 'pending' OR user_id = :viewer_scan)  ← author sees own pending

  tag_vibes feed (TagVibe::feed(), feedDiversity(), feedFillTrending(), listSorted()):
    WHERE scan_status NOT IN ('pending', 'removed')   ← same as posts
    Author-profile view (feedByAuthor()):
    WHERE scan_status != 'removed'
      AND (scan_status != 'pending' OR user_id = :viewer_scan)  ← author sees own pending

  moments (Moment::listActiveForUsers()):
    WHERE scan_status NOT IN ('pending', 'removed')   ← contacts don't see pending
    Own moments (listActiveByUser()):
    WHERE scan_status != 'removed'                    ← author sees own pending moments
    findById() (for markViewed, viewers, update, delete):
    WHERE scan_status != 'removed'                    ← author can act on pending/sensitive

  tag_space_posts feed (TagSpacePost::feedBySpace()):
    WHERE scan_status != 'removed'
      AND (scan_status != 'pending' OR tss.user_id = :viewer_user_id)  ← author sees own pending
    findById(), searchInSpace(), searchInSpaceLike():
    WHERE scan_status != 'removed'                    ← only 'removed' is hard-hidden

  Summary:
  - 'removed' is hidden everywhere, for everyone including the author.
  - 'pending' is hidden from everyone except the author (across all content types).
  - 'sensitive' is always included; is_sensitive = true in the response payload.


================================================================================
10. RESPONSE FIELDS (NSFW-RELATED)
================================================================================

  All content type responses include:
    "scan_status":  "clean" | "sensitive" | "removed" | "pending"
    "is_sensitive": true | false   (true iff scan_status == "sensitive")

  Frontend contract:
    is_sensitive = true  → blur media, show "Sensitive Content" overlay with
                            "Tap to view" action and "Request Review" button.
    scan_status = "pending" → show "Pending review" badge (only author sees this).
    scan_status = "removed" → post will not appear in API responses at all
                               (404 for direct fetch, absent from feeds).


================================================================================
11. SHARING RULES (CROSS-CONTENT INTERACTION)
================================================================================

  Space post → public post (shareFromTagSpace):
    BLOCKED if scan_status = 'sensitive' → 422 error
    BLOCKED if scan_status = 'pending'   → 422 error
    BLOCKED if scan_status = 'removed'   → 404 (findById filters removed posts)
    ALLOWED if scan_status = 'clean'     → creates public post, marks clean directly

  This means only clean space posts can cross the space/public boundary.
  Sensitive content is space-contained.


================================================================================
12. FILES INVOLVED
================================================================================

  PHP Controllers:
    app/Controllers/PublicPostController.php     — store() + shareFromTagSpace()
    app/Controllers/TagVibeController.php        — store() + remaster()
    app/Controllers/MomentController.php         — store()
    app/Controllers/TagSpacePostController.php   — store()
    app/Controllers/ContentReviewController.php  — review request + admin

  PHP Models:
    app/Models/PublicPost.php          — feed visibility filters
    app/Models/TagVibe.php             — feed visibility filters
    app/Models/Moment.php              — feed visibility filters
    app/Models/TagSpacePost.php        — feed visibility filters
    app/Models/NsfwScanQueue.php       — enqueue / markProcessed
    app/Models/ContentReviewRequest.php — CRUD for review requests

  PHP Services:
    app/Services/NsfwService.php       — enqueue() and markClean()
    app/Services/FirebaseService.php   — FCM push + Realtime DB writes

  Python:
    scripts/nsfw_scanner.py            — background worker (cron)

  Config:
    .env                               — FIREBASE_CREDENTIALS, ADMIN_EMAIL

  Logs:
    storage/logs/nsfw_scanner.log

  DB tables:
    nsfw_scan_queue
    content_review_requests
    public_posts.{scan_status, scan_confidence}
    tag_vibes.{scan_status, scan_confidence}
    moments.{scan_status, scan_confidence}
    tag_space_posts.{scan_status, scan_confidence}


================================================================================
13. KNOWN BUGS
================================================================================

BUG 1 — Remastered vibes stay 'pending' forever
  File: app/Controllers/TagVibeController.php, method remaster()
  Problem: remaster() calls TagVibe::create() (inserts with scan_status='pending')
           then copies media from the original. NsfwService is never called.
           The remasted vibe's scan_status stays 'pending' indefinitely.
  Result: The remaster is only visible to the author in all feed and search
          queries. No other user can see it.
  Fix: Add NsfwService::markClean('vibe', $newVibeId) after mediaModel->copyFromOriginal().
       The original was already scanned, so there's nothing to re-queue.

BUG 2 — store() returns scan_status:'pending' but DB is already 'clean'
  Files: PublicPostController::store(), TagVibeController::store()
  Problem: Both return { "scan_status": "pending" } in the 201 response,
           but NsfwService::markClean() runs synchronously before the response
           is sent, so the DB already holds 'clean' by the time the client reads it.
  Result: The client gets a stale status in the creation response and may show
          a "pending review" badge unnecessarily.
  Fix: Return scan_status: 'clean' in the creation response (or re-fetch the row).

BUG 3 — ContentReviewController::reject() reads a non-existent column
  File: app/Controllers/ContentReviewController.php, line ~159
  Problem: The notification body uses $request['scan_status'] to decide whether
           to say "removed" or "flagged". But content_review_requests has no
           scan_status column — only status (pending/approved/rejected).
           $request['scan_status'] will always be null.
  Result: The rejection push always says "It will remain flagged." even for
          content that was actually 'removed'.
  Fix: Look up the scan_status from the content table itself:
       $db->prepare("SELECT scan_status FROM {$table} WHERE id = :id")
       or read it during findById() with a JOIN.

BUG 4 — FCM color is always crimson for admin review notifications (minor)
  File: app/Services/FirebaseService.php, sendFcmPush()
  Problem: The PHP FirebaseService hardcodes the Android notification color
           as #DC143C regardless of context. The review_approved notification
           (positive event) also arrives in crimson red.
  Result: Visual inconsistency — approved notifications look like error alerts.
  Fix: Add an optional $color parameter to sendFcmPush() and pass '#4CAF50'
       (green) for review_approved, '#DC143C' for review_rejected.

BUG 5 — nsfw_scanner.py credentials path is hardcoded (minor)
  File: scripts/nsfw_scanner.py, line 46
  Problem: FIREBASE_CREDENTIALS = '/home/nsamhihp/firebase-service-account.json'
           is hardcoded in the Python script. The PHP side reads it from .env,
           but the Python script ignores .env entirely.
  Result: The Python path and PHP path could diverge if the .env value changes.
  Fix: Read the .env file in the Python script, or keep both paths in sync
       and document this dependency.


================================================================================
14. WHAT THE FRONTEND MUST HANDLE
================================================================================

On push notification received:

  data.type == "content_flagged"
    Show: "Your {content_type} has been flagged as sensitive content."
    Action button: "Request Review"
      → POST /api/v1/content/{content_type}/{content_id}/request-review
      → Show reason textarea (optional)

  data.type == "content_removed"
    Show: "Your {content_type} was removed for violating our community guidelines."
    Action button: "Request Review"
      → same endpoint as above

  data.type == "review_approved"
    Show: "Your content has been reviewed and restored."
    Action: navigate to the restored content item

  data.type == "review_rejected"
    Show: "Your appeal was reviewed. Your content remains flagged/removed."

In feed/post responses:
  is_sensitive = true  → show blur overlay with "Sensitive Content" label
  scan_status = "pending" → show a small "Under review" badge (author only sees this)
  scan_status = "removed" → will never appear in API responses

================================================================================
