Skip to content

feat(chat): add one-to-one direct messaging - #257

Merged
aquie00t merged 10 commits into
mainfrom
feature/direct-messaging
Sep 3, 2026
Merged

feat(chat): add one-to-one direct messaging#257
aquie00t merged 10 commits into
mainfrom
feature/direct-messaging

Conversation

@aquie00t

@aquie00t aquie00t commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

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:

Method Path
GET /conversations?status=&cursor=&limit=
POST /conversations
GET /conversations/unread-count
GET /conversations/:id/messages
POST /conversations/:id/messages
PATCH /conversations/:id/read
PATCH /conversations/:id/accept · /decline
POST /messages/media
DELETE /messages/:id

Realtime events: message:new, message:read, message:deleted, message:media_rejected, conversation:request.

Why these decisions

The pair is stored ordered. user_a_id always sorts before user_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. create is 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 ask otherParticipantId, 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 emits conversation:request rather than message:new. The unread badge sums ACCEPTED conversations 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 starts ACCEPTED. 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) and markRead — so they cannot drift apart.

Message media reuses the moderation pipeline rather than bypassing it. A new MediaChannel.MESSAGE_MEDIA and MediaOwnerKind.MESSAGE; SendMessageUseCase resolves every submitted URL through the existing resolveAttachableMedia, exactly as CreatePostUseCase does. 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 Notification target 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 gets message:media_rejected and the message's mediaStatus goes to REJECTED; 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.prisma adds Conversation and Message; MediaChannel and MediaOwnerKind each gain one value. Migration 20260903000000_add_conversations_and_messages was hand-written — prisma migrate dev could 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

  • Unit — 119 files, 1185 tests, all passing. 61 of them new: the Conversation entity (pair ordering, per-viewer answers from both sides, canSend across 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.
  • Moderation worker gains two cases: a rejected video is stripped from the message carrying it, and its sender is told over the thread rather than through the notification feed.
  • E2E (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:check and pnpm build are 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 the asFunction registration in use-cases.di.ts had to be updated in lockstep — parameter names are the wiring.
  • RealtimePort.emitToUser now takes a RealtimeEventPayload union 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.
  • Base is 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 article and its docs/test follow-ups). The chat work depends on that fix — it extends notificationTarget().
  • Deliberately out of scope: group chat, a user block list (declining a request is the lightweight stand-in), message editing, typing indicators, end-to-end encryption.

AI asistan: Opus 5

aquie00t and others added 10 commits September 3, 2026 12:02
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
aquie00t force-pushed the feature/direct-messaging branch from e677f17 to d7d874a Compare September 3, 2026 09:04
@aquie00t
aquie00t merged commit b4e8b00 into main Sep 3, 2026
10 checks passed
@aquie00t
aquie00t deleted the feature/direct-messaging branch September 3, 2026 09:08
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))
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

🎉 This PR is included in version 1.16.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant