feat(post): list a post's quotes and allow a quote with no text - #242
Merged
Conversation
A post can now quote another post, the way a quote tweet does: POST /posts takes an optional quotedPostId and every post response carries a quotedPost card, or null. The relation is self-referential on Post and cascades, so deleting a post deletes every post that quotes it and a card can never point at content that is gone. The card is deliberately lean - author, content, media and date, no counters and no viewer-specific flags - so embedding one costs no extra joins, and the include stops at one level, so a quote of a quote carries the post it quotes and nothing behind it. The quoted post is resolved before the write, so quoting an id that is already gone answers 404 rather than a constraint violation. The feed cache revives the nested date by hand: without it the same request would answer with a Date on a miss and a string for the next 60 seconds. Counters, notifications and a list of quoters follow in their own changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015JC6UgjwRSJ3KHToPYqBPC
Posts carry a quoteCount alongside likeCount and commentCount, so a client can draw the quote badge without counting rows. Denormalised for the same reason the other two are: the read path is far hotter than the write path, and counting the quotes relation would put a subquery on every feed item. Keeping that counter honest makes post creation a multi-step write, so CreatePostUseCase moves onto TransactionPort: the post, the quoted post's existence check and the increment now commit or roll back together. A post that existed without having been counted would leave the badge permanently short with nothing to notice it afterwards. The bot check stays outside the transaction because it only reads, and so do the cache purge and the follower fan-out, which must not hold the write open or roll it back. Deleting a quote gives the count back in the same transaction as the delete. Deleting a quoted post needs no such care: its quotes are cascaded away with it and no surviving row was counting them. Known drift, matching likeCount today: hard-deleting a user cascades their posts away without decrementing the posts those quoted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015JC6UgjwRSJ3KHToPYqBPC
Being quoted is a louder signal than a like - somebody has said something about your post to their own followers - and until now it arrived silently, as a number going up. The notification leads to the quote rather than to the post being quoted: the recipient already knows their own post, what they want to open is what was said about it, and the quote carries the original as its card anyway. That also buys the cleanup for free, since Notification.post cascades - deleting the quote takes its notification with it, with no undo path to write. NotifyQuotedAuthorUseCase sits beside NotifyNewPostUseCase and resolves the quoted author itself, so post creation gains one collaborator rather than two and its transaction keeps its shape. It runs after the commit and fire-and-forget: the post is the thing worth keeping, so a notification failure is logged rather than allowed to fail the request or roll the write back. An account quoting itself notifies nobody. First enum change in the repository; the migration carries the note about ALTER TYPE ... ADD VALUE inside Prisma's migration transaction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015JC6UgjwRSJ3KHToPYqBPC
Two gaps left by the earlier pieces of the feature. A post says "12 quotes" and there was nowhere to go from there. GET /posts/:id/quotes returns that page, newest first, each row a full post with its quote card and, for a signed-in caller, their own like and bookmark state. It reuses findAll rather than adding a parallel query: pagination, includes and viewer scoping already live there, and a second copy would drift. The post itself is resolved first, so "this post has no quotes" and "there is no such post" stay different answers. A quote with nothing added to it - a plain repost - is the most common shape of the feature and the schema forbade it. Content may now be empty, but only alongside quotedPostId; an empty post that quotes nothing is still rejected. That rule spans two fields and reads as a business rule, so it lives in the use case rather than as an unreadable anyOf in the request schema. This completes the quote feature. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015JC6UgjwRSJ3KHToPYqBPC
This was referenced Aug 30, 2026
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.
Alıntı özelliğinin son PR'ı. Base'i
maindeğil, #241'in dalı (feature/post-quote-notification) — zincir tek seferde merge edilip tek deploy olacak.Ne değişti
GET /posts/:id/quotes— bir postu alıntılayanların sayfalanmış listesi, en yeniden eskiye. Her satır tam bir post: kendiquotedPostkartıyla ve giriş yapmış çağıran içinisLiked/isBookmarkeddoldurulmuş halde.contentartık boş olabilir, ama yalnızcaquotedPostIdile birlikte. Alıntısız boş post hâlâ 400.Kararlar
findAll'aquotedPostIdfiltresi eklendi; sayfalama,includeve çağıranın like/bookmark kapsamı zaten orada yaşıyor, paralel bir sorgu zamanla kayardı.GET /posts/:idde böyle davranıyor.anyOfile ifade edilebilirdi ama ajv'nin ürettiği hata mesajı okunmaz olurdu. Mevcut "boş içerik 400" e2e testi yeşil kalıyor — hata artık şemadan değil use-case'ten geliyor.total,currentPage,limit,totalPages), yorum listesinin ince meta'sı değil — dönen şey bir post listesi.Doğrulama
pnpm test:unit→ 901/901 geçti. Yeni:get-post-quotes.usecase.test.ts(4 durum), create-post'a boş içerik için 2 durum.pnpm lint,pnpm format:check,tsc --noEmittemiz. Migration yok.get-quotes.test.ts, 6 durum + create'e 1) CI'da koşacak.Zincir tamam
#239 → #240 → #241 → bu. Dördü birlikte merge edilecek.
🤖 Generated with Claude Code
https://claude.ai/code/session_015JC6UgjwRSJ3KHToPYqBPC