§ unswayed-backend
Chat & messaging (Phase 9)
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