Skip to content

Moderation: Reports, Cases, and Notices โ€‹

Status: mostly BUILT. The report -> case -> notices pipeline shipped 2026-06-29 (P0-P4); this doc is the design of record that shaped it. The one still-unbuilt piece is the in-portal appeal flow (ยง6.1 of SEALED_BAN_RECONCILIATION_SPEC.md) - appeals are still the appeals@ourlantern.app email path. It complements two existing docs and does not duplicate them:

Scope here: the report -> case -> action workflow, the moderator notices channel, escalation, and the case lifecycle. The moderator-facing UI in apps/admin/src/admin/moderation/ now runs off real userReports (no longer mock). The user-facing surfaces were designed Storybook first (see Build plan) and are shipped.

1. What exists vs. what this designs โ€‹

PieceState
Block (user-to-user, private)Built. users/{uid}/blocks/{blockedUid} via blockService.js; bidirectional invisibility enforced server-side in block.service.js. Multi-block pattern detection is stubbed (blockAggregate).
Sealed two-control ban (backend)Built. POST /auth/moderation/ban (disable userId), /ban-phone (number), /unban (by userId), /unban-phone (lift a standalone number ban with no user attached) in moderation.js.
Admin Moderation UIBuilt (2026-06-29). Case list + drawer + lifecycle run off REAL userReports (caseService), not mock. The Manual ban view + case Ban call the Express moderation routes via moderationApi.js.
Admin Users-page BanBuilt + repointed. Now calls POST /auth/moderation/ban (sealed-aware, blocks re-registration) instead of the legacy banUser Cloud Function (which only disabled Auth login).
Report intake + userReportsBuilt (2026-06-29). Client-write intake (reportService.js submitReport, live in Chat.jsx) gated by the userReports rules; evidence to a locked Storage path.
Cases (aggregation + lifecycle)Built (2026-06-29). userReports grouped by reportedUserId + moderationCases state (caseService.js): claim/release, escalate, priority, dismiss; status denormalized back onto the reports.
Moderator notices (messaging)Built (2026-06-29). Server POST /auth/moderation/notice writes per-recipient notices docs (+ FCM push) + adminActions; the user reads them via the in-app inbox (noticesService / ModerationNoticeModal).
EscalationBuilt (2026-06-29). Priority derived from severity + report volume + a manual escalate toggle; queue sorted by priority. (blockAggregate many-independent-blocks signal still stubbed.)

The throughline: block, direct bans, AND the report -> case -> notices pipeline are all built (P0-P4, 2026-06-29); the sections below are the DESIGN that shaped them. The one still-unbuilt piece is the in-portal appeal flow (ยง6.1): appeals still use the appeals@ourlantern.app email path, not the OTP-proven, number-blind, one-click-reinstate flow. (This doc reads as design because it predates the build; the model still holds.)

2. Report vs. Block (the core distinction) โ€‹

These are two different controls that are easy to conflate. They are complementary, not alternatives.

BlockReport
Who actsA user, for themselvesA user, flagging for staff
VisibilityPrivate to the blockerSurfaced to moderators (a case)
EffectImmediate bidirectional invisibilityNone on its own; triggers review
Staff involvementNoneYes (review, possible ban)
Mental model"I don't want to interact with them""They broke the rules; someone should look"
Reversible by the userYes (unblock)No (it is a report on record)
Carries evidenceA private reason note (never sent anywhere)User-submitted evidence sent to staff

Key considerations:

  • They stack. A user can block and report, block only, or report only. Blocking is the immediate self-protection; reporting is the escalation. The UI should make both available without forcing one.
  • E2EE makes the reporter the source of truth. Because chat is end-to-end encrypted, the server cannot read the conversation to gather evidence. A report therefore carries the reporter's own description/context (and optionally screenshots they choose to attach). See chat retention + E2EE moderation model. Admins act on user-submitted evidence, not server-read content.
  • The private block-reason is not a report. Today the block flow collects an optional reason and stores it only in the blocker's private subcollection (blockService.js). That is intentionally private; it must not be silently turned into an admin-visible report. Reporting is an explicit, separate, consenting act.
  • Volume of blocks is a signal, not a report. Many users independently blocking one account is a soft signal (the stubbed blockAggregate). It can feed escalation/prioritization, but it is not the same as a filed report.

3. The case model โ€‹

A case is the unit moderators work. It is the aggregation of all reports filed against one reportedUserId (the correlation field). One reported account = one case, with N reports attached.

Lifecycle: open -> reviewing -> actioned | dismissed.

  • open - in the queue, no moderator has picked it up.
  • reviewing - a moderator has claimed it (owns it). See section 7.
  • actioned - a ban/restriction was applied.
  • dismissed - reviewed, no violation.
  • Reinstating a banned account returns the case to reviewing.

Proposed userReports schema (one doc per report, modeled on the existing venueClosureReports pattern):

userReports/{reportId}
  reportedUserId   string   // correlation key; the account being reported
  reportedBy       string   // reporter userId (Lantern identity, not PII)
  category         string   // harassment | spam | impersonation | threats | other
  reason           string   // user-submitted description (evidence)
  evidence?        string[] // optional user-attached references
  status           string   // open | reviewing | actioned | dismissed (case-level, denormalized)
  createdAt        Timestamp
  reviewedBy?      string   // moderator userId once claimed
  reviewNotes?     string

Cases are derived by grouping userReports on reportedUserId (or materialized into a moderationCases doc if we want per-case status/assignment/notes/activity to live in one place - decide at build time). Firestore rules: create if reportedBy == auth.uid; read only by isAdmin(); admin-only status updates; no delete (audit trail) - mirroring venueClosureReports.

4. Functionality A - Reporting (intake) โ€‹

The buildable user-facing entry point.

  • Action: a "Report" affordance in the app (in the same surface as Block, or a combined "Block and report"). Opens a small flow: pick a category, write a description (the evidence), optionally also block.
  • Write: a userReports doc keyed by reportedUserId.
  • Read: the admin Moderation page queries userReports, groups by reportedUserId, and renders cases.
  • Identity: everyone is shown to moderators by Lantern name + userId only (no PII), consistent with the sealed-identity model. The reported account's userId is the key; the reporter's Lantern identity is recorded for follow-up notices.

5. Functionality B - Moderator notices (messaging) โ€‹

"Can we message the reporter and the reported user?" Yes, with a hard E2EE boundary.

  • Boundary: admins cannot enter the E2EE conversation (cannot read or write user chats). Notices are one-way system messages, separate from chat.
  • To the reporter: acknowledgement and outcome ("we reviewed your report; action was taken").
  • To the reported user: a warning, a restriction notice, or a ban notice with the appeal address (appeals@ourlantern.app, already used by the ban gates).
  • Channel: a transactional notices mechanism (push + an in-app "notices" inbox). Decide whether to reuse existing push or add a dedicated channel at build time.
  • Tone/templates: predefined, neutral templates with a free-text field; logged to the case activity and the adminActions audit trail.

6. Functionality C - Escalation โ€‹

Not built; the levers:

  • Volume. N reports against the same account auto-flags/raises priority. The prototype already aggregates reports per reportedUserId, so "3+ reports -> flag" (the SAFETY_MECHANICS.md heuristic) is a small step. The stubbed blockAggregate (many independent blocks) is a secondary soft signal.
  • Severity. Certain categories (threats, safety) jump the queue immediately, independent of volume.
  • Manual. A moderator escalates a case to a senior/admin.
  • Mechanics. Either an escalated flag/priority field on the case, or queue ordering by (severity, report count, age). Keep it simple first: a priority derived from severity + count, plus a manual escalate button.

7. Open vs. Reviewing - the toggle โ€‹

reviewing should mean a specific moderator owns the case, not just "someone glanced."

  • Prototype today: auto-flips open -> reviewing on open. Fine for a solo moderator; it claims a case just by looking.
  • Target: an explicit "Claim" / "Start review" action, ideally with assignment to a moderator. reviewing = claimed by @moderator. This prevents two moderators working the same case and gives accountability. Swapping the auto-flip for a claim button is a one-line change in the prototype.

8. Privacy and sealed-identity integration โ€‹

  • No PII. Reporters and reported users are Lantern name + userId only. No real names, emails, or phone numbers in the case UI or userReports.
  • E2EE. Evidence is user-submitted (section 2); the server never reads chats.
  • Ban integration. When a moderator bans from a case, it drives the two-control sealed ban: disable the userId (login) and, when the number is available, ban the phoneHash (re-registration) - or capture the number at the banned user's next login. The case never stores a phone-to-userId link. See SEALED_ACCOUNT_BAN_FLOW.md. The admin Users-page Ban and the Moderation Manual ban view already call this backend via moderationApi.js (POST /auth/moderation/ban | /ban-phone); the case-driven ban will reuse the same client.
  • Reporter exposure. The reporter's Lantern identity is visible to moderators (for follow-up) but never disclosed to the reported user in a notice.

9. Build plan - Storybook-first for user-facing, Admin direct โ€‹

Two tracks, deliberately different:

  • Admin (moderator UI): keep iterating directly in apps/admin against mock data, then wire to userReports / moderation.js when the backend lands. It is an internal tool; live iteration is fine.
  • User-facing surfaces: design in Storybook first, design-review, then integrate into the app. These touch every user, so they get the design gate. Surfaces to prototype as stories:
    1. Report a user - category + description + optional "also block" (sheet/modal).
    2. Block vs. Report - how the two relate at the point of action; relative to the existing BlockConfirmationModal (which already has a story).
    3. Moderation notice - the in-app notice a user receives (warning / outcome / ban + appeal).

Sequence: Storybook design play -> approve -> build userReports + rules + notices backend -> wire the Admin page off mock data -> integrate the user-facing surfaces.

Issues to file: (a) report intake + userReports collection + rules; (b) moderator notices channel; (c) escalation rules; (d) claim/assign for the case lifecycle.

10. Open questions / decisions needed โ€‹

  1. Claim vs. auto-on-open for reviewing (section 7). Recommend explicit claim + assignment.
  2. Block + Report combined, or separate actions? Recommend separate, with an optional "also block" checkbox in the report flow (keeps the private block and the consenting report distinct).
  3. Auto-flag threshold - is 3 reports the escalation trigger? Severity overrides?
  4. Notices channel - reuse existing push, or a dedicated notices inbox?
  5. Case storage - derive cases from userReports on the fly, or materialize a moderationCases doc for per-case status/assignment/notes/activity?
  6. Reporter anonymity - confirmed never shown to the reported user; confirm retention of reportedBy for repeat-reporter abuse detection.

Built with VitePress