feat(rooms): read-only / moderators-only posting policy (LC-85) #149

Merged
nrupard merged 1 commit from feat/lc-85-readonly-rooms into main 2026-05-19 16:36:51 +02:00
Owner

Closes LC-85. Adds a per-room posting policy: admins can flip a room into moderators_only (announcement-style) or admins_only mode. Default all preserves existing behavior. Reactions, pins, and edits-of-own-old-posts are intentionally NOT gated (per the ticket's carve-out).

Schema

  • Migration 0029 adds rooms.posting_allowed_for TEXT NOT NULL DEFAULT 'all' CHECK (posting_allowed_for IN ('all','moderators_only','admins_only')). DEFAULT backfills implicitly.

Server enforcement (acceptance criterion: forged POST returns 403)

  • routes::room::post_message now calls a can_post_with_policy helper after the access check. The helper consults db::room_rbac::is_room_moderator (LC-84), so a per-room Moderator override unlocks moderators_only for a caller whose org role is plain User. admins_only requires effective Admin.

Compose-box rendering (acceptance criterion: disabled for users below threshold)

  • RoomPage gains can_post: bool and posting_locked_reason: &str. room/page.html renders the composer when can_post, otherwise a centered notice ("Only moderators/admins can post in this room."). Server enforcement runs regardless of the client toggle.

Admin UI (acceptance criterion: admins can flip from room settings)

  • /room/{id}/moderators (the LC-84 admin page) gains a "Posting policy" section above the override list with a 3-option dropdown (Everyone (default), Moderators only, Admins only). Submits to POST /room/{id}/posting-policy, gated on the same room_can_manage_overrides predicate the override grant/revoke endpoints use.

Audit log (acceptance criterion: a log entry records the policy change)

  • The policy POST writes a room_posting_policy row to mod_actions carrying the room id, actor, and new policy in the metadata JSON.

Reversibility (acceptance criterion: switching out is reversible; messages unchanged)

  • Policy lives in a column, not a constraint on messages. Switching back to all immediately restores everyone's post rights; nothing on disk references the policy.

Out of scope (open questions in the ticket)

  • /poll (LC-66) and /remind (LC-63): those features do not exist yet. When built, they should route through can_post_with_policy.
  • Bot bypass (LC-73): bots are not yet built.
  • "Broadcast announcement to all rooms" remains a separate feature.

Tests

  • New routes_readonly_rooms.rs (6/6 passing): admin sets policy (303), plain member denied (403), invalid policy 400, member POST in moderators-only room → 403, admin POST → 200, moderator override unlocks posting for a member, policy flip writes the expected mod_actions row.
  • just check clean. just test and just test-saas pass (no failures introduced).

Migration drift: 0029 added to the six hand-list tests (db_uploads, uploads_sweep, routes_reconnect, routes_uploads, db_enclave, admin_uploads) and to db_private_rooms.rs (verbose form). Tests under common::pool pick it up via sqlx::migrate!.

Closes LC-85. Adds a per-room posting policy: admins can flip a room into `moderators_only` (announcement-style) or `admins_only` mode. Default `all` preserves existing behavior. Reactions, pins, and edits-of-own-old-posts are intentionally NOT gated (per the ticket's carve-out). Schema - Migration 0029 adds `rooms.posting_allowed_for TEXT NOT NULL DEFAULT 'all' CHECK (posting_allowed_for IN ('all','moderators_only','admins_only'))`. DEFAULT backfills implicitly. Server enforcement (acceptance criterion: forged POST returns 403) - `routes::room::post_message` now calls a `can_post_with_policy` helper after the access check. The helper consults `db::room_rbac::is_room_moderator` (LC-84), so a per-room Moderator override unlocks `moderators_only` for a caller whose org role is plain User. `admins_only` requires effective Admin. Compose-box rendering (acceptance criterion: disabled for users below threshold) - `RoomPage` gains `can_post: bool` and `posting_locked_reason: &str`. `room/page.html` renders the composer when `can_post`, otherwise a centered notice ("Only moderators/admins can post in this room."). Server enforcement runs regardless of the client toggle. Admin UI (acceptance criterion: admins can flip from room settings) - `/room/{id}/moderators` (the LC-84 admin page) gains a "Posting policy" section above the override list with a 3-option dropdown (`Everyone (default)`, `Moderators only`, `Admins only`). Submits to `POST /room/{id}/posting-policy`, gated on the same `room_can_manage_overrides` predicate the override grant/revoke endpoints use. Audit log (acceptance criterion: a log entry records the policy change) - The policy POST writes a `room_posting_policy` row to `mod_actions` carrying the room id, actor, and new policy in the metadata JSON. Reversibility (acceptance criterion: switching out is reversible; messages unchanged) - Policy lives in a column, not a constraint on `messages`. Switching back to `all` immediately restores everyone's post rights; nothing on disk references the policy. Out of scope (open questions in the ticket) - `/poll` (LC-66) and `/remind` (LC-63): those features do not exist yet. When built, they should route through `can_post_with_policy`. - Bot bypass (LC-73): bots are not yet built. - "Broadcast announcement to all rooms" remains a separate feature. Tests - New `routes_readonly_rooms.rs` (6/6 passing): admin sets policy (303), plain member denied (403), invalid policy 400, member POST in moderators-only room → 403, admin POST → 200, moderator override unlocks posting for a member, policy flip writes the expected `mod_actions` row. - `just check` clean. `just test` and `just test-saas` pass (no failures introduced). Migration drift: 0029 added to the six hand-list tests (`db_uploads`, `uploads_sweep`, `routes_reconnect`, `routes_uploads`, `db_enclave`, `admin_uploads`) and to `db_private_rooms.rs` (verbose form). Tests under `common::pool` pick it up via `sqlx::migrate!`.
feat(rooms): read-only / moderators-only posting policy (LC-85)
All checks were successful
Check / clippy + fmt + tests (pull_request) Successful in 1m12s
02ba04084a
Adds a per-room policy that limits who can post messages. Default keeps existing behavior (every room member can post); admins flip a room to `moderators_only` or `admins_only` for announcement-style channels. Reactions, pins, and edits-of-own-old-posts are unaffected (the spec explicitly carves those out).

Schema:
- Migration 0029 adds `rooms.posting_allowed_for TEXT NOT NULL DEFAULT 'all' CHECK (posting_allowed_for IN ('all','moderators_only','admins_only'))`. The DEFAULT backfills existing rows in place, so no data migration is needed.

Code:
- `models::Room` gains the `posting_allowed_for` field; `db::chat::map_room` and every `SELECT * FROM rooms` site is updated. `db::chat::set_room_posting_policy` writes the column.
- `routes::room::post_message` calls a new `can_post_with_policy` helper after the existing access check. The helper reuses `db::room_rbac::is_room_moderator` (LC-84) so a per-room Moderator override unlocks a `moderators_only` room even when the caller's org role is plain User. `admins_only` requires effective Admin.
- `RoomPage` view gains `can_post: bool` and `posting_locked_reason: &str`; `room/page.html` renders the composer when `can_post`, otherwise a "Only moderators/admins can post in this room." notice. Server enforcement still runs even when the composer is hidden.
- `routes::room_rbac::post_posting_policy` handles `POST /room/{id}/posting-policy`. Authorization gated on the same `room_can_manage_overrides` predicate the moderator-grant endpoints use, so site admin / enclave owner / enclave admin (or anyone with an Admin override - though the override-Admin path is not exposed in the UI yet) can flip the policy. The handler writes a `room_posting_policy` row to `mod_actions` so the change is in the audit log.
- The room moderators page (`/room/{id}/moderators`) gains a "Posting policy" section above the override list with a 3-option dropdown.

Out of scope (open questions in the ticket, deferred):
- Whether `/poll` (LC-66) and `/remind` (LC-63) honor the policy: those features do not yet exist; when they do, they should route through the same `can_post_with_policy` helper.
- Whether designated bots (LC-73) bypass: bots are not yet built.
- "Broadcast announcement to all rooms" remains a separate feature.

Tests:
- New integration binary `routes_readonly_rooms.rs` (6 tests): admin can set policy (303 redirect), plain member cannot (403), invalid policy rejected (400), a moderators-only room rejects member POSTs (403) but accepts admin POSTs (200), a moderator override unlocks posting for a previously-member user, and the policy flip writes a `room_posting_policy` row to `mod_actions` carrying the right metadata.
- `just check` clean; `just test` / `just test-saas` pass.

Migration drift: 0029 added to the six hand-list tests (`db_uploads`, `uploads_sweep`, `routes_reconnect`, `routes_uploads`, `db_enclave`, `admin_uploads`) plus `db_private_rooms.rs` (verbose form). Tests under `common::pool` pick it up via `sqlx::migrate!`.
nrupard deleted branch feat/lc-85-readonly-rooms 2026-05-19 16:36:51 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!149
No description provided.