feat(chat): add one-to-one direct messaging - #257
Merged
Conversation
A media rejection on a comment carried only the comment id. An article is read by slug, and the slug travels with a notification only when articleId is set, so a rejected attachment on an article comment produced a notice the author could not tap. The worker now resolves the comment and passes its article or post alongside. Also corrects the documented meaning of mediaStatus on posts and comments. It never becomes REJECTED: a refused file is dropped from mediaUrls and the content returns to APPROVED, which is what keeps a post that loses one of several attachments serving the rest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A2WFyQ3PR2jYvDVk89yZpc
The client team needs the whole surface in one place: the new error responses on all four upload endpoints, the ownership rule that makes a second post reusing an upload fail, the two new response fields, the notification type and the pending-video behaviour. Pasting it into a chat lost the first half twice, so it lives here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A2WFyQ3PR2jYvDVk89yZpc
The existing cases were written against a shape taken from the docs. This one is a body the live API actually returned, and it carries the things a made-up fixture leaves out: `none` at 0.99 beside the class scores, `context` and `suggestive_classes` nested among them, and `weapon` reporting a map where every other model reports a number. Reading any of those as a probability would flag clean files - `none` is highest on exactly the images nothing is wrong with - so the test asserts the parser returns eleven keys and no more. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A2WFyQ3PR2jYvDVk89yZpc
Two users can now hold a private conversation. The pair is stored ordered - user_a_id always sorts before user_b_id - which is the only thing that makes the unique constraint on the pair mean anything: without it (a,b) and (b,a) are two different rows, and the same two people end up with two threads the moment they write to each other at once. The repository upserts on that pair so the loser of that race joins the thread rather than failing on the constraint. That ordering stops at the entity: callers ask who the other person is, how many messages they have not seen, and whether they may write, and never touch the A/B columns. An open inbox needs a way to survive strangers, so a conversation opened by somebody the recipient does not follow starts PENDING. It lands in a requests tab, only the initiator may write to it, and it emits conversation:request rather than message:new - the unread badge sums accepted conversations only, so an account nobody asked to hear from cannot interrupt. Following is already consent to be written to, so a thread from someone the recipient follows skips straight to ACCEPTED. Declining keeps the row: deleting it would let the refused account open a fresh request the moment it is refused, which makes declining a gesture rather than a decision. Read state lives on the conversation rather than on each message. Marking a thread read is one row update instead of an update across every message in it, and the badge is a sum over conversations instead of a scan of the message table. Both counters and both watermarks are written in exactly two places - sending a message, inside its transaction, and marking one read - so they cannot drift. Message media rides the pipeline that already exists, through its own upload channel. The channel is fixed when the bytes arrive, so a file uploaded for a private conversation can never be attached to a public post, and sending resolves every submitted URL back to an asset this sender uploaded before it is stored - the same check that keeps arbitrary URLs out of a post body. A rejected video reaches its sender as a realtime event instead of a notification, because the notification target can only point at public content and a message notification would be one nobody can tap. Messages are withdrawn rather than deleted: the other side may have replied to one, and removing the row would leave that reply answering nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011nFtPcaUsKpJwiUV2Hk9EY
The paths worth pinning are the ones a mistake would quietly reopen. A stranger's first message has to land in the requests tab without raising the unread badge, the recipient must not be able to reply before accepting, and a declined conversation must stay closed - each of those is one boolean away from an inbox anyone can shout into. The media cases cover the check that makes the moderation pipeline non-optional for messages: a key somebody else uploaded, a key uploaded through the post channel, and a key that lost the attach race are all refused, and a message carrying an unscanned video is stored withholding it. The entity tests are mostly about the ordered pair, since that is the one piece of storage detail everything else is written to be ignorant of: the same two people must land on the same row whichever way round they are passed, and every per-viewer answer must be right from both sides. Two cases are added to the moderation worker: a rejected video is stripped from the message carrying it, and its sender is told over the thread rather than through the notification feed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011nFtPcaUsKpJwiUV2Hk9EY
The ordered participant pair and the request gate are both easy to undo by accident - one looks like redundant sorting, the other like an extra status nobody reads. Writing down why they are there keeps the next change from removing them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011nFtPcaUsKpJwiUV2Hk9EY
Both listings ordered by a timestamp with an id tiebreaker but filtered on the timestamp alone. Ordering only decides how a page is sorted, never which rows it contains, so `WHERE ts < cursor` dropped every row sharing the boundary timestamp - three messages committing in the same millisecond is ordinary in a live thread, and the comment above the query claimed the tiebreaker prevented exactly this. The cursor now carries the id too, and the predicate resumes at `(ts < T) OR (ts = T AND id < lastId)`. The inbox had a second way to lose rows. `lastMessageAt` is null until a thread has a message, and Postgres sorts NULLs first in a DESC order, so every empty conversation pinned above every active one - and opening a thread from a profile creates one, so they accumulate. A cursor can only carry a value, so once a page ended inside that block there was nothing to resume from: `nextCursor` came back null and everything below became unreachable. Conversations now sort on `lastActivityAt`, which is never null, and `lastMessageAt` keeps its narrower job of saying whether the thread has anything in it. Sorting on activity also reads better - a thread you just opened sits at the top, and one you opened and abandoned sinks. The cursor is opaque base64url, following the feed's, so the encoding stays ours to change: a client that can read a cursor eventually constructs one. A cursor that cannot be decoded is treated as absent rather than as an error, and the reader gets the first page - which is what somebody holding a truncated or stale one wants to see. The migration is amended rather than followed by an ALTER: it has not been applied anywhere yet, and shipping a table only to change it on the next line is noise. Anyone who did apply it locally needs a `migrate reset`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011nFtPcaUsKpJwiUV2Hk9EY
Two defects in the moderation worker, both about what happens after a video is refused. They share a file and a review, so they share a commit. **The owner never recorded the rejection.** `refreshOwner` resolved the status as "pending if anything is still unscanned, approved otherwise", so content whose every attachment was refused came out APPROVED with an empty media list - indistinguishable from content that never carried media. That made `mediaRejected` on a message response unreachable: the flag the schema promises would render a "media removed" notice never fired, and a media-only message whose single attachment was refused reloaded as a silent empty row. The realtime event does not cover this; it is one shot, reaches the sender only while they are connected, and leaves nothing behind for the next load. Content is now marked REJECTED when it has attachments and none can be served. A partial rejection stays APPROVED - the files that survived are still worth serving, and the refused one drops out of the list. Posts and comments record the same state; their responses do not surface it today, but the stored value should be true either way. **The retry path skipped the message channel.** An asset is rejected two ways: the provider says so, or the retry budget runs out. Only the first checked whether the owner was a message; the second called `notifyUploader` unconditionally. A direct-message video that failed `maxAttempts` consecutive provider calls therefore produced a MEDIA_REJECTED notification whose target resolved to nothing - untappable - and the sender's client never learned the attachment was gone. A rejection that took the retry path is no less private than one that took the other, so the choice between the two channels now lives in one place both call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011nFtPcaUsKpJwiUV2Hk9EY
Opening a conversation is idempotent - the client calls it every time somebody taps "message" on a profile - but the endpoint replied 201 either way. A client keying a "conversation started" toast off the status would fire it for a thread that has been sitting there for weeks, including a declined one it cannot write to. The use case now reports whether it created anything, and the route declares both shapes. `created` is derived from whether a conversation was found before writing, so two callers racing to open the same thread can both come back true; the upsert behind them still yields one row, and only the status code overstates it, in a window a client cannot act on differently. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011nFtPcaUsKpJwiUV2Hk9EY
`markRead` took an id and re-read the row to work out which side of the pair the reader sat on - a second round-trip, on a hot path, for something the use case was already holding, having just loaded the conversation to check membership. It then cleared the counter by assigning zero. That assignment is the real problem. `applyNewMessage` increments the same column, so an increment committing between the read and this write was simply overwritten: a message that arrived while the thread was open came back already marked read, and the badge stayed short until the next one forced another increment. The realtime event covers a connected client, but not a reload or a dropped socket. It is now one statement. The caller hands over the conversation it already has, which carries both the reader's side and the number of messages they were actually shown, and the write takes away exactly that number - so a message that arrived since stays counted. A `gte` guard keeps two overlapping reads from subtracting the same messages twice and driving the counter negative. Adds the repository's first integration tests: the ordered pair resolving from either direction, the upsert joining rather than failing, both markRead races, the unread sum crossing both columns, and cursor paging through rows that share an activity timestamp. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011nFtPcaUsKpJwiUV2Hk9EY
aquie00t
force-pushed
the
feature/direct-messaging
branch
from
September 3, 2026 09:04
e677f17 to
d7d874a
Compare
github-actions Bot
pushed a commit
that referenced
this pull request
Sep 3, 2026
# [1.16.0](v1.15.0...v1.16.0) (2026-09-03) ### Features * **chat:** add one-to-one direct messaging ([#257](#257)) ([b4e8b00](b4e8b00))
|
🎉 This PR is included in version 1.16.0 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
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
Adds one-to-one direct messaging. Two users can hold a private conversation with text and media, delivered over the WebSocket channel that already exists, with a message-request gate so an open inbox cannot be used as a megaphone.
New surface, all behind
fastify.authenticate:GET/conversations?status=&cursor=&limit=POST/conversationsGET/conversations/unread-countGET/conversations/:id/messagesPOST/conversations/:id/messagesPATCH/conversations/:id/readPATCH/conversations/:id/accept·/declinePOST/messages/mediaDELETE/messages/:idRealtime events:
message:new,message:read,message:deleted,message:media_rejected,conversation:request.Why these decisions
The pair is stored ordered.
user_a_idalways sorts beforeuser_b_id. Without that,(a,b)and(b,a)are two different rows and the unique constraint means nothing — the same two people get two threads the moment they write to each other at once.createis an upsert on the pair, so the loser of that race joins the winner's thread instead of failing on the constraint. The ordering stops at the entity: callers only ever askotherParticipantId,unreadFor,canSend,isRequestFor, and never touch the A/B columns.A stranger's first message is a request, not a notification. A conversation opened by someone the recipient does not follow starts
PENDING: it lands in a requests tab, only the initiator may write to it, and it emitsconversation:requestrather thanmessage:new. The unread badge sumsACCEPTEDconversations only, so an account nobody asked to hear from cannot raise it. Following is already consent to be written to, so a thread from someone the recipient follows startsACCEPTED. Declining keeps the row — deleting it would let the refused account open a fresh request immediately, which turns declining into a gesture rather than a decision.Read state lives on the conversation, not the message. Marking a thread read is one row update instead of an update across every message in it, and the badge is a sum over conversations instead of a scan of the message table. Counters and watermarks are written in exactly two places —
applyNewMessage(inside the message's transaction) andmarkRead— so they cannot drift apart.Message media reuses the moderation pipeline rather than bypassing it. A new
MediaChannel.MESSAGE_MEDIAandMediaOwnerKind.MESSAGE;SendMessageUseCaseresolves every submitted URL through the existingresolveAttachableMedia, exactly asCreatePostUseCasedoes. Without that the pipeline would be decorative here — the request body accepts arbitrary URLs, and a private thread is a perfectly good place to deliver one. The channel is fixed at upload time, so a file uploaded for a conversation can never be attached to a public post.A rejected message video is reported over the thread, not the notification feed. The
Notificationtarget can only point at a post, article or comment, so a notification about a private message would be one the reader cannot tap. The sender getsmessage:media_rejectedand the message'smediaStatusgoes toREJECTED; the recipient is told nothing, because the read path withheld the file all along and for them it never existed.Messages are withdrawn, not deleted. The other side may have replied to one; removing the row would leave that reply answering nothing. The row survives, the text does not.
Data model
prisma/models/conversation.prismaaddsConversationandMessage;MediaChannelandMediaOwnerKindeach gain one value. Migration20260903000000_add_conversations_and_messageswas hand-written —prisma migrate devcould not reach the database (P1000), and the repository's existing migrations are hand-authored in the same style. It has not been applied against a live database yet.Tests
Conversationentity (pair ordering, per-viewer answers from both sides,canSendacross all three states),StartConversation(follower →ACCEPTED, stranger →PENDING, bot/deleted/self refused, no second thread, no reopening a decline),SendMessage(empty message, request gate, declined thread, media owned by someone else, media from the post channel, media that lost the attach race, unscanned video withheld),RespondToRequest,MarkConversationRead(no read receipt on a request),GetMessages(cursor paging),DeleteMessage.tests/e2e/conversation/direct-message.test.ts) walks the whole flow: open → request lands in the requests tab → badge stays at zero → recipient cannot reply → accept → badge moves → both sides write → thread reads newest-first → mark read → withdraw → decline stays closed → non-participant gets a 404.pnpm lint,pnpm format:checkandpnpm buildare clean.Not run locally: E2E and integration suites need Postgres and Redis, so they are left to CI.
Notes for review
ModeratePendingMediaUseCase's constructor grew two parameters (messageRepository,realtimeService). DI is awilix CLASSIC, so theasFunctionregistration inuse-cases.di.tshad to be updated in lockstep — parameter names are the wiring.RealtimePort.emitToUsernow takes aRealtimeEventPayloadunion rather than the notification shape alone. Existing callers are unaffected; a chat event still has to produce a payload that is wholly one shape or wholly the other.main, so this PR also carries three media-moderation commits that were left behind when feat(media): moderate every uploaded image and video #253 was merged (fix(media): point a rejected comment's notice at its articleand its docs/test follow-ups). The chat work depends on that fix — it extendsnotificationTarget().AI asistan: Opus 5