feat: room subsystem (visibility, multi-owner membership, invites/requests) + time-based reaper#7
Merged
Merged
Conversation
…ests Builds out the room subsystem: room lifecycle, an ownership/membership model, and the two ways into a private room (request-to-join and invite). Schema: - rooms gain is_public and is_discoverable and drop owner_id; ownership moves to memberships.is_owner, so a room can have many owners. - memberships gain is_owner and joined_at, with a partial index on owners. - new room_invites and room_join_requests tables (pending rows keyed by IDs, created_at, ON DELETE CASCADE); invited_by is ON DELETE SET NULL for audit. Access-control contract: - An owner is always a member. A room may have ZERO owners (e.g. after its sole owner is deleted) — allowed, because every owner-gated action also permits admins, so an ownerless room stays manageable. - Visibility drives joining: public → join now; private+discoverable → queue a join request; private+non-discoverable → invite-only, reported as NoRoomExists so existence stays hidden. - Read gets hide unauthorized private rooms; owner-gated mutations collapse missing-room and not-authorized both to Failed. Neither leaks existence. Commands: - NewRoom (visibility flags; seeds the creator as first owner-member), AddRoomOwner (additive grant to an existing member), SetRoomName. - GetRoom / GetRoomMembership, both gated; membership returns a user_id-free PublicUser shape so the internal PK never reaches the wire. - JoinRoom / LeaveRoom; join requests via Approve/Reject plus GetMy/GetIncoming invites via Accept/Decline plus GetMyInvites. - The owner-or-admin gate is a single gate_room() helper, not five copies. DeleteUser no longer escalates owned rooms to the default admin — ownership is a membership flag that cascades with the user, and ownerless rooms are now a supported state. Tests: websocket-level integration coverage for every command (happy path, authorization, idempotency/NoChange, existence-hiding), and a wire-level assertion that membership payloads carry no user_id. Room seeders added to tests/common as pure additions.
Several tables hold data that would otherwise grow without bound: messages accumulate, rooms linger after they empty out, and pending invites/join requests are never cleared unless a client acts on them. Introduce a background reaper that ages all of it out on a timer, so retention doesn't depend on client action. - reap() runs a single transaction that deletes, all past retention_days: - messages older than their timestamp, - rooms with no messages whose id (uuidv7) predates the window, - room_invites and room_join_requests by created_at. (Invites/requests for a deleted room already go via ON DELETE CASCADE; the explicit deletes catch stale rows in rooms that survive.) - reap() is public so it can be invoked directly and unit-tested. - Config gains retention_days and reap_interval_secs (defaults 30d / hourly), both optional env vars. - Spawned from main alongside auth. - tests/reaper.rs drives reap() directly: stale (40d) invites/requests are swept, fresh (1d) rows survive, and a within-window run is a no-op.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Builds out the room subsystem on top of the existing users/messages schema, and adds a background reaper so room-related data can't accumulate unbounded. Two commits:
Everything is client-facing and wired end to end (client → server dispatch → room actor → Postgres).
Schema
(init migration edited in place — no release exists yet)
rooms: addis_public/is_discoverable, dropowner_id.memberships: addis_ownerandjoined_at, with a partial index on owners.room_invitesandroom_join_requests(pending rows,created_at,ON DELETE CASCADE;invited_byisON DELETE SET NULLfor audit).Access-control model
Enforced in the actor (SQL can't assert it cheaply):
DeleteUserno longer escalates owned rooms to the default admin.NoRoomExistsso existence stays hidden.NoRoomExistsfor an unauthorized private room (indistinguishable from a missing one); owner-gated mutations collapse "no such room" and "not authorized" both toFailed.user_id-freePublicUsershape; request/invite views are name-only.Commands
NewRoom,AddRoomOwner,SetRoomNameGetRoom,GetRoomMembershipJoinRoom,LeaveRoomApproveJoinRequest,RejectJoinRequest,GetMyJoinRequests,GetIncomingJoinRequestsInviteToRoom,AcceptInvite,DeclineInvite,GetMyInvitesThe owner-or-admin gate is a single
gate_room()helper.Reaper
retention_days: old messages, empty rooms whose id (uuidv7) predates the window, and staleroom_invites/room_join_requests.retention_daysandreap_interval_secs(defaults 30d / hourly), both optional env vars.mainalongside auth, on the shared shutdown token.Test plan
cargo test— 124 passing (41 new room/reaper tests + existing suite), via#[sqlx::test]per-test databases.new_room,add_room_owner,set_room_name,get_room,get_room_membership,join_leave,join_requests,invites,reaper— each covering happy path, authorization, idempotency/NoChange, and existence-hiding; a wire-level test asserts membership payloads carry nouser_id.cargo fmt --all --checkandcargo clippy --all-targets -- -D warningsclean.Notes / follow-ups
retention_daysgoverns every table; a separate, shorter window for pending invites/requests can be added later as an additive knob.