Appearance
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):
- 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.
- Internal strike system. Every confirmed violation writes a
ModerationStrikeagainst 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.- 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
Reportis opened for messages — exceptsexual/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.- Manual report buttons on posts, jobs, user profiles, and applications — users can report content the system missed. The backend
reportContentmutation 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):
| Tier | Trigger (category scores) | Automated action |
|---|---|---|
| SEVERE | sexual/minors at any confidence; illicit/violent very high | Remove 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 |
| VIOLATION | sexual high (pornography), illicit high, violence/graphic high | Auto-unlist/hide (comments: remove) + notify author with category + system report + strike |
| BORDERLINE | any category in a mid band | Content stays live; silent system report for admin review |
| CLEAN | below all thresholds | Store 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 generatealone is not enough) - Edit:
castyou-backend/src/models/mongo/Message.ts—moderationsubdocument - 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: addsource("USER" | "SYSTEM", default "USER"),category String?(SEXUAL | SEXUAL_MINORS | ILLEGAL | VIOLENCE | OTHER),severity String?(URGENT | NORMAL); widen documentedtargetTypeset 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: addUNLISTEDto 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: addmoderationStatus String @default("ACTIVE")("ACTIVE" | "UNLISTED" | "REMOVED") — orthogonal tovisibility(a PRIVATE pornographic item still violates ToS).Application: addmoderationStatus String @default("ACTIVE")+moderationReason String?.PostComment(schema.prisma:1068): addmoderationStatus String @default("ACTIVE")("ACTIVE" | "REMOVED") +moderationReason String?— removed comments render a tombstone, body never served to non-admins.UserActivityLog: new action valuesAUTO_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+ModerationStrikerows 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.ts—scanText(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 existingOPENAI_API_KEY); modelomni-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
SETEXonsha256(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.ts—startModerationScanWorker() - 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.ts—sendMessagefast 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 theModerationScanrow, then hands the verdict to the BE-MOD-004 enforcement service. - Message fast path:
sendMessagereturns immediately and fires the scan as a detached promise (direct service call, no queue round-trip — "live" per refinement #3): scan text (+mediaUrls) → writeModerationScan→ on VIOLATION/BORDERLINE setmoderation.flaggedon the Message doc; onsexual/minorshand 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: truewithoutsendMessagelatency increasing - Worker writes a
ModerationScanrow 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 typesCONTENT_FLAGGED,CONTENT_REMOVED,CONTENT_RESTORED,ACCOUNT_SUSPENDED_MODERATION(add to GraphQLenum NotificationTypetoo — 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/REMOVEDpath (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 getmoderation.flagged(BE-MOD-003 fast path) and flow through the recipient-decides flow (BE-MOD-007); onlysexual/minorsmessages 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 writessource: 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: URGENTreport. - Every auto-action: one transaction → status change + strike +
UserActivityLog(AUTO_MODERATION)+ systemReport(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 theModerationScanrow + silent BORDERLINE-style report;enforced: falserecorded.- Also fix in passing: report-resolution currently notifies the reporter with a generic
PROFILE_VIEW(reports.ts:106) — switch to a properREPORT_RESOLVEDtype.
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.ts—adminReports(page, pageSize, status, source, category, severity) - Edit:
castyou-backend/src/graphql/resolvers/adminContent.ts+services/admin/contentModeration.ts—removeComment/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; allrequireAdmin, all reason-audited) - Create:
castyou-backend/src/graphql/resolvers/adminStrikes.ts—userStrikes(userId, page, pageSize),addStrike(userId, category, note),revokeStrike(id, note); strike count surfaced on theadminUser/adminUsersprojections - Create:
castyou-backend/src/graphql/resolvers/adminConversations.ts—adminConversations(search, userId, page, pageSize)+adminConversation(id, page, pageSize)(paginated messages, flagged ones badged with category); search-only, never a browsable list: asearchterm (participant handle/name/email) or auserIdis 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 writesUserActivityLog(CONVERSATION_LOOKUP)with the admin id + conversation id — message privacy demands the access itself be audited - Edit:
castyou-backend/src/graphql/schema/index.ts— exposeModerationScanon Report detail (scan { verdict categories modelVersion scannedAt });urgentReportCountquery 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:
adminReportsfilterable 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.ts—MODERATION_ENFORCEflag (seeded off; env override for tests) - Create:
castyou-backend/src/graphql/resolvers/adminModerationStats.ts—adminModerationStats(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.tsbands - 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 — exposeMessage.moderation { flagged category recipientAction }(to the recipient only — the sender must not learn their message was flagged); new mutationresolveFlaggedMessage(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-ticketcreateReelRenderFailureTicket) - 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). - KEEP →
recipientAction: KEPT, warning never shown again. DELETE →recipientAction: 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 (ModerationScanid), and the recipient's optional comment. - Admin resolving that ticket against the sender uses BE-MOD-005 tools:
hideMessage,addStrike(source: SUPPORT_TICKET), and existingsuspendUser/banUserfor repeat offenders (strike history makes the pattern visible). - No admin
Reportrow 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_ENFORCEgates 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/severityfilters, 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 fromModerationScan, 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 (reasonSelect: sexual content / illegal content / harassment / spam / other + free-text description), headlessonSubmit - Create:
castyou-frontend/apps/app/src/hooks/useReportContent.ts— wraps existingreportContent(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— acceptAPPLICATIONtarget (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: USERreport 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]]).