-
+
{isOwner && (
<>
diff --git a/apps/server/src/lib/services/post.test.ts b/apps/server/src/lib/services/post.test.ts
index 6256620f..8b9d6509 100644
--- a/apps/server/src/lib/services/post.test.ts
+++ b/apps/server/src/lib/services/post.test.ts
@@ -85,6 +85,32 @@ describe('postService.list', () => {
expect(post!.body).toBe('Para one.\n\nPara two.')
expect(JSON.stringify(post)).not.toContain('Para three')
})
+
+ it('omits liked for an anonymous reader', async () => {
+ await create()
+ expect((await postService.list())[0]!.liked).toBeUndefined()
+ })
+
+ it('marks liked true for a signed-in reader who liked the post', async () => {
+ const post = await create()
+ const reader = await signUpReader()
+ await LikeModel.create({ user: reader._id, post: new Types.ObjectId(post.id) })
+ expect((await postService.list(reader._id.toString()))[0]!.liked).toBe(true)
+ })
+
+ it('marks liked false for a signed-in reader who has not liked the post', async () => {
+ await create()
+ const reader = await signUpReader()
+ expect((await postService.list(reader._id.toString()))[0]!.liked).toBe(false)
+ })
+
+ it("does not mark liked true from another reader's like", async () => {
+ const post = await create()
+ const otherReader = await signUpReader()
+ await LikeModel.create({ user: otherReader._id, post: new Types.ObjectId(post.id) })
+ const reader = await UserModel.create({ username: 'reader2', email: 'r2@example.com', password: 'x' })
+ expect((await postService.list(reader._id.toString()))[0]!.liked).toBe(false)
+ })
})
describe('postService.list — search and tag filters', () => {
@@ -225,6 +251,24 @@ describe('postService.getBySlug — THE gating rule (spec §6)', () => {
it('throws NotFoundError for an unknown slug', async () => {
await expect(postService.getBySlug('nope', undefined)).rejects.toThrow(NotFoundError)
})
+
+ it('omits liked for an anonymous reader', async () => {
+ const { slug } = await create()
+ expect((await postService.getBySlug(slug, undefined)).liked).toBeUndefined()
+ })
+
+ it('marks liked true when the viewer has liked the post', async () => {
+ const { id, slug } = await create()
+ const reader = await signUpReader()
+ await LikeModel.create({ user: reader._id, post: new Types.ObjectId(id) })
+ expect((await postService.getBySlug(slug, reader._id.toString())).liked).toBe(true)
+ })
+
+ it('marks liked false when the viewer has not liked the post', async () => {
+ const { slug } = await create()
+ const reader = await signUpReader()
+ expect((await postService.getBySlug(slug, reader._id.toString())).liked).toBe(false)
+ })
})
describe('postService.update', () => {
diff --git a/apps/server/src/lib/services/post.ts b/apps/server/src/lib/services/post.ts
index 8fb23275..c9d6fba1 100644
--- a/apps/server/src/lib/services/post.ts
+++ b/apps/server/src/lib/services/post.ts
@@ -26,6 +26,8 @@ export type PostDto = {
author: PostAuthor
tags: string[]
likeCount: number
+ /** This viewer's own like status. Present only when there's a viewerId — see toDto. */
+ liked?: boolean
coverImage?: string
/** Derived from `coverImage` at serialization time — see deliveryUrl. */
coverUrl?: string
@@ -54,7 +56,7 @@ function isPopulated(author: unknown): author is PopulatedAuthor {
function toDto(
post: HydratedDocument
,
likeCount: number,
- { full, gated }: { full: boolean; gated: boolean },
+ { full, gated, liked }: { full: boolean; gated: boolean; liked?: boolean },
): PostDto {
const author = post.author
// REGRESSION GUARD (legacy postsList.jsx:12): `body` is required by the schema,
@@ -73,6 +75,7 @@ function toDto(
: { id: String(author), username: '' },
tags: post.tags ?? [],
likeCount,
+ liked,
coverImage: post.coverImage ?? undefined,
coverUrl: post.coverImage ? deliveryUrl(post.coverImage) : undefined,
createdAt: post.createdAt,
@@ -98,6 +101,20 @@ async function countLikes(postId: Types.ObjectId): Promise {
return LikeModel.countDocuments({ post: postId })
}
+/**
+ * One query for the whole page, not one per post: which of these post ids has
+ * this viewer liked. Returns undefined for an anonymous viewer so callers can
+ * tell "no session" apart from "liked none of them".
+ */
+async function likedPostIds(
+ postIds: Types.ObjectId[],
+ viewerId?: string,
+): Promise | undefined> {
+ if (!viewerId || postIds.length === 0) return undefined
+ const likes = await LikeModel.find({ user: viewerId, post: { $in: postIds } }).select('post')
+ return new Set(likes.map((like) => like.post.toString()))
+}
+
export const postService = {
/**
* The feed. Teaser bodies ALWAYS — a list endpoint never ships full bodies,
@@ -140,12 +157,14 @@ export const postService = {
// in a stable, meaningful order rather than whatever the index yields.
query.sort(term ? { score: { $meta: 'textScore' }, createdAt: -1 } : { createdAt: -1 })
const posts = await query
+ const liked = await likedPostIds(posts.map((p) => p._id), viewerId)
return Promise.all(
posts.map(async (p) =>
toDto(p, await countLikes(p._id), {
full: false,
gated: !viewerId,
+ liked: liked?.has(p._id.toString()),
}),
),
)
@@ -156,7 +175,8 @@ export const postService = {
if (!post) throw new NotFoundError('Post not found.')
const full = Boolean(viewerId)
- return toDto(post, await countLikes(post._id), { full, gated: !full })
+ const liked = await likedPostIds([post._id], viewerId)
+ return toDto(post, await countLikes(post._id), { full, gated: !full, liked: liked?.has(post._id.toString()) })
},
async create(input: CreatePost, authorId: string): Promise {