Skip to content

Epic 37 — Automated Content Moderation (Illegal Content & Pornography)

Decision (user, 2026-06-12): Every piece of user-generated content — posts, jobs, applications, messages, and user profiles (incl. portfolio media) — is automatically scanned for illegal matter and pornography. Depending on the violation type the content is auto-unlisted/removed, the author is notified why, and a system report is opened for a human admin to review and take final action (confirm, restore, escalate to suspend/ban).

Engine decision: OpenAI omni-moderation-latest — verified free (does not count toward API usage limits), handles text + images in one call, and we already ship an OpenAI client (services/ai/flierGenerator.ts). Videos are sampled into frames via ffmpeg (already a worker dependency) and moderated as images. Net new infrastructure cost: ~$0 — reuses existing BullMQ/Redis, R2, Mongo notifications, and the Epic 33 moderation/report primitives.

Rollout decision: shadow mode first. Ship the full pipeline behind MODERATION_ENFORCE=false — scans run and write audit rows + silent system reports, but nothing is unlisted and nobody is notified. Tune thresholds on real data for 1–2 weeks, then enable enforcement (severe tier first, then full).

Refinements (user, 2026-06-12):

  1. Post comments are in scope. A violating comment is removed (not unlisted — comments have no direct-link value) and the comment author is notified why.
  2. Internal strike system. Every confirmed violation writes a ModerationStrike against the offending user. Strikes are admin-internal only — never shown to the user, no automatic punishment tied to a count (for now); they exist so an admin reviewing a report/user sees the pattern at a glance.
  3. Messages do NOT involve admins in the auto-flag flow. A flagged message is scanned live at send time (fast direct call, not the queue) and the recipient decides: the message renders with a "possibly inappropriate" warning and the recipient chooses Keep / Delete / Report user. Reporting opens a support ticket (Epic 28) carrying the message body + message id, both user ids, and the flag reason/category. No system Report is opened for messages — except sexual/minors, which bypasses recipient discretion entirely (hidden + quarantined + urgent system report; legal obligation). Separately, admins get a conversation lookup tool (search conversations/messages by user), read-only and audit-logged.
  4. Manual report buttons on posts, jobs, user profiles, and applications — users can report content the system missed. The backend reportContent mutation already exists (Epic 28/33); the frontend has no UI for it today — this epic ships the buttons + adds the APPLICATION target.

Why: A talent marketplace with public profiles, media portfolios, an open feed, comments, and 1:1 messaging is exposed to pornographic and illegal content the moment it opens to the public; today only user-filed reports exist (Epic 28/33), post moderation is fully manual, and there is no report button anywhere in the app UI. Epic 33 already built the hard parts — Post UNLISTED/REMOVED states + moderation audit fields, the Report model + adminReports queue, suspendUser/banUser with JWT invalidation, UserActivityLog — so this epic adds the automated detection layer in front of them and extends the same pattern to Jobs, MediaItems, Messages, and Applications.

Tier model (thresholds env-configurable, tuned in shadow mode):

TierTrigger (category scores)Automated action
SEVEREsexual/minors at any confidence; illicit/violent very highRemove content + quarantine media in R2 (evidence — never delete) + auto-suspend talent pending review (producers: content removed + urgent report only, suspension stays manual — business blast radius) + notify + urgent system report
VIOLATIONsexual high (pornography), illicit high, violence/graphic highAuto-unlist/hide (comments: remove) + notify author with category + system report + strike
BORDERLINEany category in a mid bandContent stays live; silent system report for admin review
CLEANbelow all thresholdsStore scan row only

Messages exception: the tier table applies to posts, comments, jobs, applications, media, and profiles. Messages follow the recipient-decides flow (refinement #3 above): VIOLATION/BORDERLINE → flag + recipient prompt (Keep / Delete / Report → support ticket), no admin report, no automatic strike (a message strike is only written when an admin resolves the resulting support ticket against the sender). Only sexual/minors escalates a message into the SEVERE path.

Legal note: any sexual/minors hit triggers reporting obligations (NCMEC in the US, national hotlines in EU/BR). Code responsibility = detect + preserve evidence (quarantined R2 object, scan row); the actual filing is a human/legal step — document in the admin runbook, out of code scope.

Scope: Backend pipeline + admin UI + user-facing notifications/appeal + report buttons. Scanning is async post-publish for posts/comments/jobs/applications/media/profiles (content goes live instantly, pulled within seconds on violation). Messages get a live fast path: text-only moderation fired immediately on send as a non-blocking parallel call (the send itself never waits or fails on it — fail-open), so the flag is virtually always present before the recipient reads it. All new UI from @castyou/design-system ([[Design System Rule]]); all lists paginated ([[Pagination Rule]]).


BE-MOD-001 — Schema: ModerationScan, ModerationStrike, Report extensions, moderation states on Job/MediaItem/Application/PostComment/Message

  • [x] Done

Files:

  • Edit: castyou-backend/prisma/schema.prisma
  • Run: pnpm db:migrate (Postgres schema change — prisma generate alone is not enough)
  • Edit: castyou-backend/src/models/mongo/Message.tsmoderation subdocument
  • Edit: castyou-backend/src/graphql/schema/index.ts — new types/fields/enums

Schema changes (Prisma):

  • New ModerationScan: id, targetType (POST | COMMENT | JOB | APPLICATION | MESSAGE | MEDIA_ITEM | USER_PROFILE), targetId, verdict (CLEAN | BORDERLINE | VIOLATION | SEVERE), categories Json (raw per-category scores), modelVersion, enforced Boolean (false in shadow mode), scannedAt. Index on (targetType, targetId) and (verdict, scannedAt).
  • New ModerationStrike (admin-internal only — never exposed to non-admin GraphQL): id, userId (offender), targetType, targetId, category, source ("AUTO" | "ADMIN" | "SUPPORT_TICKET"), reportId String?, note String?, createdById String? (null for AUTO), revokedAt DateTime? (admin can revoke instead of delete — audit stays), createdAt. Index on (userId, createdAt). No automatic punishment is tied to the count — it's signal for the human.
  • Report: add source ("USER" | "SYSTEM", default "USER"), category String? (SEXUAL | SEXUAL_MINORS | ILLEGAL | VIOLENCE | OTHER), severity String? (URGENT | NORMAL); widen documented targetType set to include COMMENT, APPLICATION, MEDIA_ITEM (MESSAGE is deliberately not a Report target — message issues flow through support tickets, except the SEVERE legal path).
  • Job: add UNLISTED to the status set (GraphQL schema already hints at it — schema/index.ts:88-90) + the same moderation fields Post got in Epic 33 (moderatedById, moderatedAt, moderationReason, moderationAction).
  • MediaItem: add moderationStatus String @default("ACTIVE") ("ACTIVE" | "UNLISTED" | "REMOVED") — orthogonal to visibility (a PRIVATE pornographic item still violates ToS).
  • Application: add moderationStatus String @default("ACTIVE") + moderationReason String?.
  • PostComment (schema.prisma:1068): add moderationStatus String @default("ACTIVE") ("ACTIVE" | "REMOVED") + moderationReason String? — removed comments render a tombstone, body never served to non-admins.
  • UserActivityLog: new action values AUTO_MODERATION, MODERATION_APPEAL, CONVERSATION_LOOKUP, STRIKE_ADDED, STRIKE_REVOKED.

Mongo (Message): moderation?: { flagged: boolean; hidden: boolean; category?: string; scannedAt?: Date; recipientAction?: 'KEPT' | 'DELETED' | 'REPORTED'; recipientActionAt?: Date }hidden is reserved for the SEVERE path; flagged-but-not-hidden messages await the recipient's decision.

Acceptance criteria:

  • Migration applies cleanly; existing rows default to ACTIVE/USER-sourced (no behavior change)
  • ModerationScan + ModerationStrike rows queryable; strikes invisible to non-admin GraphQL paths
  • GraphQL exposes new Report fields + Job UNLISTED + moderationStatus on MediaItem/Application/PostComment

BE-MOD-002 — Moderation service: OpenAI wrapper, tiers, hash cache, video frame sampler

  • [x] Done

Files:

  • Create: castyou-backend/src/services/moderation/index.tsscanText(texts), scanImages(urls), classify(scores) → tier
  • Create: castyou-backend/src/services/moderation/videoFrames.ts — ffmpeg frame sampling
  • Create: castyou-backend/src/services/moderation/thresholds.ts — tier bands, env-overridable
  • Create: castyou-backend/src/__tests__/services/moderation.test.ts

Notes:

  • OpenAI client init mirrors flierGenerator.ts (uses existing OPENAI_API_KEY); model omni-moderation-latest; text + image URLs (R2 CDN URLs work directly) in one request where possible.
  • Fail-open: if the key is missing or the API errors after retries, log + return UNSCANNED (content stays live); never block a user write on moderation.
  • Video: sample 1 frame / ~8s, cap ~10 frames (reuse fluent-ffmpeg patterns from services/reel/renderer.ts / workers/mediaProcessor.ts), moderate frames as images, take per-category max.
  • Dedup cache: Redis SETEX on sha256(text) / media key → cached verdict (TTL ~30d) so re-edits/repeated messages don't re-scan; admin-restore writes a whitelist entry so restored content isn't re-flagged on next edit. Per-user rescan rate cap as queue-flood protection.
  • classify() is a pure function over the score map → easy unit testing of tier boundaries.

Acceptance criteria:

  • Tier classification unit-tested at boundaries (incl. sexual/minors → SEVERE at any confidence)
  • Video sampler produces ≤10 frames and aggregates max scores
  • Cache hit skips the API call; API failure → fail-open verdict, logged

BE-MOD-003 — moderation-scan queue + worker + enqueue hooks at all write sites

  • [x] Done

Files:

  • Create: castyou-backend/src/workers/moderationScanner.ts — BullMQ queue + worker (pattern: workers/mediaProcessor.ts)
  • Edit: castyou-backend/src/index.tsstartModerationScanWorker()
  • Edit (one-line enqueue each): post create/edit resolver, comment create resolver, job create/edit (incl. AI flier text), application submit, portfolio addPortfolioItem/updatePortfolioItem (chain after the media-processing thumbnail job so the R2 object is confirmed), profile-edit resolvers (bio/headline/avatar → targetType USER_PROFILE)
  • Edit: castyou-backend/src/services/messaging/index.tssendMessage fast path (see Notes; not the queue)
  • Create: castyou-backend/src/__tests__/workers/moderationScanner.test.ts

Notes:

  • Job payload { targetType, targetId }; worker assembles the scannable payload per type (text fields + media URLs), calls the BE-MOD-002 service, writes the ModerationScan row, then hands the verdict to the BE-MOD-004 enforcement service.
  • Message fast path: sendMessage returns immediately and fires the scan as a detached promise (direct service call, no queue round-trip — "live" per refinement #3): scan text (+ mediaUrls) → write ModerationScan → on VIOLATION/BORDERLINE set moderation.flagged on the Message doc; on sexual/minors hand off to the SEVERE path. Any error → log + message stays normal (fail-open). Media-bearing messages may take a few seconds — acceptable; the flag lands before or shortly after first read.
  • Concurrency ~5, 3 attempts, exponential backoff; terminal failure → log + fail-open (content stays live).
  • Enqueue is fire-and-forget at the service layer — a Redis outage must never fail the user's write (same stance as createNotification).

Acceptance criteria:

  • All seven content types are scanned on create/edit; user-facing writes unaffected by queue/scan failures
  • A flagged message has moderation.flagged: true without sendMessage latency increasing
  • Worker writes a ModerationScan row for every processed job
  • Verified end-to-end with a mocked moderation service

BE-MOD-004 — Enforcement: per-type unlist/hide, auto-suspend, notifications, system reports, restore + appeal

  • [x] Done

Files:

  • Create: castyou-backend/src/services/moderation/enforce.ts — applies tier actions transactionally
  • Edit: castyou-backend/src/services/notifications/index.ts — new types CONTENT_FLAGGED, CONTENT_REMOVED, CONTENT_RESTORED, ACCOUNT_SUSPENDED_MODERATION (add to GraphQL enum NotificationType too — see the Epic 7 v2 parity-guard lesson)
  • Edit: castyou-backend/src/graphql/resolvers/reports.ts — system-report creation; appealModeration(reportId) mutation
  • Edit: castyou-backend/src/services/feed/index.ts, job listing/search, portfolio + talent resolvers, messaging read path — exclude non-ACTIVE / hidden content
  • Edit: castyou-backend/src/services/media/index.ts — R2 quarantine move (non-CDN prefix) for SEVERE media
  • Create: castyou-backend/src/__tests__/services/moderationEnforce.test.ts

Notes:

  • Per-type actions: Post → existing UNLISTED/REMOVED path (services/admin/contentModeration.ts); PostComment → moderationStatus: REMOVED (tombstone render, body withheld from non-admins, author notified with category); Job → UNLISTED (out of feeds/search/open-roles; direct-link semantics mirror Post); MediaItem → moderationStatus (excluded from profile/portfolio/apply-picker render); Application → hidden from the producer's applicant list, talent notified. Messages are NOT enforced here — VIOLATION/BORDERLINE messages only get moderation.flagged (BE-MOD-003 fast path) and flow through the recipient-decides flow (BE-MOD-007); only sexual/minors messages enter this enforcement path (moderation.hidden + quarantine + urgent report).
  • Strikes: every auto-enforced VIOLATION/SEVERE writes a ModerationStrike(source: AUTO) against the author; admin Confirm on a user-filed report writes source: ADMIN; a message strike is written only when an admin resolves the recipient's support ticket against the sender (source: SUPPORT_TICKET, from BE-MOD-007). Admin Restore (false positive) revokes the strike (revokedAt).
  • SEVERE: quarantine R2 object + auto-suspendUser (talent only — producer suspension stays a manual admin call) + severity: URGENT report.
  • Every auto-action: one transaction → status change + strike + UserActivityLog(AUTO_MODERATION) + system Report(source: SYSTEM) + author notification with human-readable category (never raw scores). The author notice is a moderation event, not a message notification, so the [[Notifications vs Messages]] rule is not violated.
  • Appeal: notification links to appealModeration → flips the report back to PENDING with an appealed marker + UserActivityLog(MODERATION_APPEAL). No appeal for SEXUAL_MINORS.
  • Restore (admin, false positive): content returns to prior status, author gets CONTENT_RESTORED, Redis whitelist entry written.
  • MODERATION_ENFORCE=false (shadow mode): skip every action above except the ModerationScan row + silent BORDERLINE-style report; enforced: false recorded.
  • Also fix in passing: report-resolution currently notifies the reporter with a generic PROFILE_VIEW (reports.ts:106) — switch to a proper REPORT_RESOLVED type.

Acceptance criteria:

  • Each tier produces exactly the documented side-effects per content type; CLEAN produces none; VIOLATION/BORDERLINE messages produce no enforcement here (flag only)
  • Filters verified: violating content absent from feed/search/profile/applicant list; removed comment renders tombstone with body withheld
  • Strikes written on auto-enforcement and admin confirm, revoked on restore; never readable through non-admin GraphQL
  • Suspend fires for talent SEVERE only; appeal + restore flows round-trip; shadow mode takes no visible action

BE-MOD-005 — Admin GraphQL: report filters, per-type moderation mutations, strikes, conversation lookup

  • [x] Done

Files:

  • Edit: castyou-backend/src/graphql/resolvers/reports.tsadminReports(page, pageSize, status, source, category, severity)
  • Edit: castyou-backend/src/graphql/resolvers/adminContent.ts + services/admin/contentModeration.tsremoveComment/restoreComment, unlistJob/removeJob/restoreJob, removeMediaItem/restoreMediaItem, hideMessage/restoreMessage (admin tools for the SEVERE path and support-ticket resolution — not part of the recipient flow), hideApplication/restoreApplication (mirror the existing post trio; all requireAdmin, all reason-audited)
  • Create: castyou-backend/src/graphql/resolvers/adminStrikes.tsuserStrikes(userId, page, pageSize), addStrike(userId, category, note), revokeStrike(id, note); strike count surfaced on the adminUser/adminUsers projections
  • Create: castyou-backend/src/graphql/resolvers/adminConversations.tsadminConversations(search, userId, page, pageSize) + adminConversation(id, page, pageSize) (paginated messages, flagged ones badged with category); search-only, never a browsable list: a search term (participant handle/name/email) or a userId is REQUIRED — calling with neither returns a validation error, so there is no "page through all conversations on the platform" path; results are only the matched participant's conversations. Read-only, and every lookup writes UserActivityLog(CONVERSATION_LOOKUP) with the admin id + conversation id — message privacy demands the access itself be audited
  • Edit: castyou-backend/src/graphql/schema/index.ts — expose ModerationScan on Report detail (scan { verdict categories modelVersion scannedAt }); urgentReportCount query for the nav badge
  • Create/extend resolver tests

Notes: admins must be able to view UNLISTED/REMOVED/hidden content inline from the report (bypass visibility filters when ctx.user.isAdmin within the report-detail path only). Confirm/escalate reuse existing resolveReport, suspendUser, banUser. Strikes and conversation lookup are admin-internal: no non-admin path may ever read them.

Acceptance criteria:

  • adminReports filterable by source/category/severity, paginated
  • Moderation mutations exist for all six content types with audit logging
  • Strikes: listable per user (active + revoked), manually addable/revocable, count visible on admin user views; invisible to non-admins
  • Conversation lookup requires a search term or userId (no-filter call rejected — no global conversation list exists); pages messages, flags badged; every access audit-logged
  • Report detail exposes the scan scores; non-admins can never read flagged content through these paths

BE-MOD-006 — Shadow-mode flag, threshold tuning view, historical backfill

  • [x] Done

Files:

  • Edit: castyou-backend/src/services/featureFlags/seed.tsMODERATION_ENFORCE flag (seeded off; env override for tests)
  • Create: castyou-backend/src/graphql/resolvers/adminModerationStats.tsadminModerationStats(range): scan counts by verdict/category/targetType, would-have-actioned list while in shadow mode
  • Create: castyou-backend/scripts/backfill-moderation.ts — enqueue scans for existing public content (posts, open jobs, public media items) at low concurrency

Acceptance criteria:

  • Flag off → full pipeline runs in shadow (rows + silent reports only); flipping on requires no deploy
  • Stats query gives enough signal to tune thresholds.ts bands
  • Backfill is idempotent (skips targets with an existing scan) and rate-limited

BE-MOD-007 — Flagged-message recipient flow: keep / delete / report → support ticket

  • [x] Done

Files:

  • Edit: castyou-backend/src/graphql/schema/index.ts + messaging resolvers — expose Message.moderation { flagged category recipientAction } (to the recipient only — the sender must not learn their message was flagged); new mutation resolveFlaggedMessage(messageId, action: KEEP | DELETE | REPORT, comment: String)
  • Edit: castyou-backend/src/services/messaging/index.ts — recipient-action handling on the Message doc
  • Edit: castyou-backend/src/services/support/createFlaggedMessageTicket(...) (pattern: the Epic 7 v2 auto-ticket createReelRenderFailureTicket)
  • Create: castyou-backend/src/__tests__/resolvers/flaggedMessage.test.ts

Notes:

  • Only the recipient of a flagged message may call resolveFlaggedMessage; one decision per message (idempotent — repeat calls return the recorded action).
  • KEEPrecipientAction: KEPT, warning never shown again. DELETErecipientAction: DELETED, message body hidden for the recipient (sender's view untouched — they keep what they wrote). REPORT → delete-for-recipient + open a SupportTicket (category: CONTENT) under the recipient's account containing: message body + message id + conversation id, sender + recipient user ids, the auto-flag category/scores reference (ModerationScan id), and the recipient's optional comment.
  • Admin resolving that ticket against the sender uses BE-MOD-005 tools: hideMessage, addStrike(source: SUPPORT_TICKET), and existing suspendUser/banUser for repeat offenders (strike history makes the pattern visible).
  • No admin Report row and no notification to the sender at any point in this flow — admins only get involved if the recipient reports ([[Notifications vs Messages]] stays intact: the recipient prompt is rendered inline in the thread, not as a notification).
  • Shadow mode: messages still get flagged internally but the recipient prompt is suppressed (MODERATION_ENFORCE gates the exposure, not the scan).

Acceptance criteria:

  • Recipient sees flag + can keep/delete/report exactly once; sender never sees any flag signal
  • REPORT opens a support ticket with message body, ids, both users, and flag reason
  • Non-recipient callers get FORBIDDEN; shadow mode suppresses the prompt

FE-MOD-001 — Admin moderation queue UI (filters, inline content, scores, one-click actions)

  • [x] Done

Files:

  • Edit: castyou-frontend/apps/app/src/pages/admin/AdminReportsPage.tsx (or current reports screen) — source/category/severity filters, URGENT badge styling
  • Create: castyou-frontend/apps/app/src/pages/admin/AdminReportDetail.tsx (or extend existing) — inline flagged-content preview (post/comment/job/media/application), category-score panel from ModerationScan, actions: Confirm (keep down; optional suspend/ban), Restore, Escalate
  • Edit: admin user detail page — Strikes panel: count badge + paginated list (category, source, target link, date, revoked state), add-strike + revoke actions
  • Create: castyou-frontend/apps/app/src/pages/admin/AdminConversationsPage.tsx — conversation lookup: opens to an empty search state (no conversations shown until the admin searches a participant — there is deliberately no browse-all list); results show only the matched user's conversations; open one read-only with paginated messages, flagged messages badged with category; "access is logged" notice
  • Edit: admin nav — urgent-report count badge (urgentReportCount, poll like /admin/reels)
  • Edit: castyou-frontend/packages/i18n/locales/{en,pt,es}/app.json
  • Tests

Acceptance criteria:

  • Admin can work the queue end-to-end: filter → open report → see content + scores → confirm/restore/escalate, paginated ([[Pagination Rule]]), DS components only ([[Design System Rule]])
  • Strike history visible on the admin user view (incl. revoked); add/revoke work
  • Conversation lookup: search → open → read messages with flag badges; read-only
  • SEVERE/URGENT reports are visually impossible to miss (badge + nav count)

FE-MOD-002 — User-side: moderation notifications, removed-content placeholders, appeal flow

  • [x] Done

Files:

  • Edit: castyou-frontend/apps/app/src/pages/NotificationsPage.tsx (+ bell panel items) — render the four new types with icon, human-readable category, and target link
  • Edit: message thread UI — flagged-message recipient prompt: flagged incoming message renders blurred/collapsed with a "This message may contain inappropriate content (<category>)" warning and three actions: Keep (reveal, never ask again), Delete (hide it for me), Report user (optional comment → resolveFlaggedMessage(REPORT), confirmation that a support ticket was opened). Sender's own bubble shows nothing. Plus "removed by moderation" placeholder for SEVERE-hidden messages
  • Edit: post comment threads — removed-comment tombstone ("Comment removed by moderation"; author additionally sees the category + appeal)
  • Edit: PortfolioPage / TalentProfilePage — flagged media card (owner sees reason + appeal; public render simply excludes it)
  • Edit: applications views — hidden-application state for the talent
  • Create: appeal action (button on the notification/flagged card → appealModeration), confirmation + "under review" state
  • Edit: i18n en/pt/es
  • Tests

Acceptance criteria:

  • Author always learns what was actioned and why (category, not scores) and can appeal (except sexual/minors)
  • Flagged message: blurred + warning until the recipient decides; Keep reveals permanently, Delete hides, Report confirms a ticket was opened; the sender's view is unchanged throughout
  • Placeholders/tombstones render for every hidden/removed content type incl. comments; no dead UI
  • Restore notification links back to the restored content

FE-MOD-003 — Report button on posts, jobs, user profiles, and applications

  • [x] Done

Files:

  • Create: castyou-frontend/packages/design-system/src/components/.../ReportButton.tsx (+ export) — presentational flag icon/menu entry + reason modal (reason Select: sexual content / illegal content / harassment / spam / other + free-text description), headless onSubmit
  • Create: castyou-frontend/apps/app/src/hooks/useReportContent.ts — wraps existing reportContent(targetType, targetId, reason, description) mutation
  • Edit: post card/detail (overflow menu), job detail, public talent + producer profiles, and the producer's application view — mount the button with the right targetType
  • Edit: castyou-backend/src/graphql/resolvers/reports.ts — accept APPLICATION target (only the job's producer may report an application — they're the only ones who can see it); dedupe repeat reports from the same reporter on the same target (return the existing pending report)
  • Edit: i18n en/pt/es
  • Tests

Notes: backend reportContent already exists (USER | JOB | POST since Epics 28/33) but no UI calls it today — this ticket is mostly frontend. User reports land in the same adminReports queue as system reports (source: USER), so FE-MOD-001 handles triage for free. Don't show the button on your own content. Success state: "Thanks — our team will review" (no promise of outcome).

Acceptance criteria:

  • Report action available on posts, jobs, user profiles, and applications (producer-only for applications); never on own content
  • Submitting creates a source: USER report visible in the admin queue; repeat reports dedupe
  • DS component ([[Design System Rule]]), i18n'd in en/pt/es, tested

TEST-MOD-001 — Tests for Epic 37

  • [x] Done (core backend + frontend unit tests; E2E left as optional follow-up)

Backend (Vitest): tier classification boundaries; per-type enforcement side-effects (status + strike + report + notification + activity log in one transaction); comment removal withholds body from non-admins; fail-open on API/queue failure; shadow-mode no-ops (incl. suppressed recipient prompt); appeal/restore round-trip incl. re-flag whitelist + strike revocation; flagged-message flow (recipient-only access, keep/delete/report idempotency, REPORT → support ticket with message/users/reason, sender never sees the flag); strikes invisible to non-admin GraphQL; conversation lookup audit-logged; reportContent APPLICATION target + producer-only guard + dedupe; admin filter/visibility paths; sexual-minors → quarantine + suspend (talent) and no appeal, incl. for messages; notification-enum parity guard covers the four new types.

Frontend (Vitest/RTL): admin queue filter/action flows; strikes panel add/revoke; conversation lookup; notification rendering per type; flagged-message prompt (blur → keep/delete/report paths); placeholders/tombstones (message, comment, portfolio, application); appeal flow; ReportButton on each surface + own-content suppression.

E2E (castyou-automated-tests, optional follow-up): seeded VIOLATION post → unlisted + notification + admin report visible; flagged message → recipient reports → support ticket exists (mock the moderation service verdict; see [[E2E Test Gotchas]]).