CODEX // oaoisme wiki

§ unswayed-backend

Chat & messaging (Phase 9)

updated 2026-06-10

Chat & messaging (Phase 9)

Phase 9's first half is the direct-messaging surface (§24) plus the post-share write (§20) — the last consumer of the social graph and the content engine. It lives in src/chat/; ADR-0033 records the decisions.

Who may chat: the mutual-follow gate, done right

POST /api/chats only opens a DM between mutual followers. The legacy check was a single set-wide exists() over the whole participant array with inverted-looking boolean logic — only accidentally correct for the 1:1 case the app uses. The rebuild's assertMutualFollow verifies per target: the caller follows X and X follows the caller, for every X; any failure answers the exact 403 You are only allowed to chat with friends (no period). Since only private 2-person chats are real, group requests are rejected outright (the participants array is capped at exactly one target) — the group/name schema columns ride along as documented dead surface until group chat is an actual feature.

Exact-set dedupe. Creating a chat never duplicates one: the service looks up a private chat whose participant set is exactly [caller, target] (count-match plus none-outside, ignoring soft-delete state). A hit answers 200 Chat already exists. and un-hides the caller's pivot; a miss creates and answers 201. One subtle, deliberately-preserved semantic: un-hiding flips is_deleted back but never clears deleted_at — that timestamp is the participant's permanent message cutoff.

Per-participant soft delete and the cutoff

DELETE /api/chats/{id}/delete doesn't delete the chat — it sets your ChatParticipant.is_deleted (hiding it from your list) and stamps your deleted_at. The chat row only hard-deletes (cascading messages) when every participant has deleted it. Until then the other party keeps chatting; any new message un-hides you — but your message list and your chat-card last_message only show messages strictly newer than your cutoff. The legacy computed this by reading global Auth inside the model; the rebuild's ChatResourceBuilder takes the viewer explicitly, so the same chat serializes correctly per recipient, in broadcasts, and in tests.

One mandated divergence (the build guide's fix, recorded in API-CONTRACT): last_message is the shared MessageResource shape — legacy leaked the raw Eloquent model there, so the chat list and the messages list disagreed about what a message looks like.

One flow to create every message

All message-producing paths — send and share — go through MessageFlowService: create the row (plus the MessagePostShare link for shares), un-hide participants, bump chat.updated_at explicitly (the chat-list sort key), then emit the broadcasts. The legacy bumped updated_at inside the MessageSent event constructor — a DB write hidden in an event, untestable, and the direct cause of the share-sorting bug (the share path constructed the event without a chatId, so shared chats never re-sorted). Events are now pure DTOs; when the flow runs inside a transaction the broadcasts are deferred until after commit; and every broadcast call is try/catch-swallowed — a dead socket layer can never fail a write.

The broadcasts themselves keep the frozen channel grammar: MessageSent on ralli.chat.{chatId} with { type: new|update|delete, message }, ChatReceived on ralli.userChats.{userId} per participant, and the NotifyUser('chat') nudge to the recipient on send. And the Phase-1 security placeholder is closed: PrismaChatMembershipChecker now restricts ralli.chat.* socket joins to verified participants (legacy channel callbacks returned true for anyone).

Messages: author-scoped, typo preserved

GET /chats/{id}/messages (participant-only, cutoff-aware, newest first, bare data.messages array — §24 lists carry no pagination meta), POST send-message (content ≤ 255; the 200 envelope carries no message body — delivery is socket-only, legacy parity), POST update-message and DELETE delete-message (author-scoped (id, chat_id, sender_id) → a foreign or missing message is uniformly 404 Message not found!). Update resets status='sent'/read_at=null; both update and delete re-broadcast, with ChatReceived fired only when the touched message is the chat's latest. The update success message is the frozen legacy typo — Message update successfully. — asserted byte-exact in e2e. Missing chat ids answer the adjudicated uniform 404 Chat not found! (legacy leaked 500s through a generic catch).

Post-share (§20): the bridge that makes shares_count real

POST /api/post/share/{id} fans a feed post into DMs: per receiver, inside one transaction — find-or-create the exact-set chat, un-hide everyone, create a message_type='share' message (content=null) plus the MessagePostShare row, bump updated_at (fixed), then broadcast after commit. The shared post is snapshotted into the message as PostShareResource (post id, the ''-fallback author block, type, content, media, status — no timestamps). A failure anywhere rolls back everything.

Two policy notes: legacy let a share open a chat with anyone — the rebuild applies the same mutual-follow gate per receiver (an adjudicated policy change; the wire shape is identical). And Phase 7's PostResource.shares_count placeholder is now the real count — the resource builder gained a fifth batched query over message_post_shares.

Testing

Five TDD slices (core flow/resources first), 100% file coverage each; two e2e suites through the adversarial author→review→patch pipeline (test/chat.e2e-spec.ts, test/post-share-lexi.e2e-spec.ts) with a recording broadcaster fake; the Postman collection gained an 18-chat-lexi folder. Lexi has its own page: Lexi AI assistant.

Subpages