diff --git a/apps/web/src/lib/server/domains/posts/__tests__/post-merge.test.ts b/apps/web/src/lib/server/domains/posts/__tests__/post-merge.test.ts index 863953d3d..96ec8697d 100644 --- a/apps/web/src/lib/server/domains/posts/__tests__/post-merge.test.ts +++ b/apps/web/src/lib/server/domains/posts/__tests__/post-merge.test.ts @@ -9,6 +9,7 @@ import type { PostId, PrincipalId } from '@opencoven-feedback/ids' const mockPostsFindFirst = vi.fn() const mockPrincipalFindFirst = vi.fn() const mockBoardsFindFirst = vi.fn() +const mockDbSelect = vi.fn() const mockDbUpdate = vi.fn() const mockDbExecute = vi.fn() const createActivity = vi.fn() @@ -21,6 +22,24 @@ function createUpdateChain() { return chain } +type SelectChain = { + from: ReturnType + innerJoin: ReturnType + where: ReturnType + orderBy: ReturnType + limit: ReturnType +} + +function createSelectChain(result: unknown[]): SelectChain { + const chain = {} as SelectChain + chain.from = vi.fn(() => chain) + chain.innerJoin = vi.fn(() => chain) + chain.where = vi.fn(() => chain) + chain.orderBy = vi.fn().mockResolvedValue(result) + chain.limit = vi.fn().mockResolvedValue(result) + return chain +} + vi.mock('@/lib/server/db', async () => { const { sql: realSql } = await vi.importActual('drizzle-orm') @@ -31,6 +50,7 @@ vi.mock('@/lib/server/db', async () => { principal: { findFirst: (...args: unknown[]) => mockPrincipalFindFirst(...args) }, boards: { findFirst: (...args: unknown[]) => mockBoardsFindFirst(...args) }, }, + select: (...args: unknown[]) => mockDbSelect(...args), update: (..._args: unknown[]) => { mockDbUpdate(..._args) return createUpdateChain() @@ -39,11 +59,11 @@ vi.mock('@/lib/server/db', async () => { }, posts: { id: 'post_id', canonicalPostId: 'canonical_post_id' }, votes: { principalId: 'principal_id', postId: 'post_id' }, - boards: { id: 'board_id', slug: 'board_slug' }, + boards: { id: 'board_id', slug: 'board_slug', isPublic: 'is_public' }, principal: { id: 'principal_id', displayName: 'display_name' }, - eq: vi.fn(), - and: vi.fn(), - isNull: vi.fn(), + eq: vi.fn((left, right) => ({ op: 'eq', left, right })), + and: vi.fn((...args) => ({ op: 'and', args })), + isNull: vi.fn((value) => ({ op: 'isNull', value })), sql: realSql, } }) @@ -84,7 +104,8 @@ vi.mock('@opencoven-feedback/ids', async (importOriginal) => { }) // Import after mocks -const { mergePost, unmergePost } = await import('../post.merge') +const { mergePost, unmergePost, getPublicMergedPosts, getPublicPostMergeInfo } = + await import('../post.merge') const POST_A = 'post_aaa' as PostId const POST_B = 'post_bbb' as PostId @@ -111,7 +132,7 @@ describe('mergePost', () => { return Promise.resolve(mockPost()) }) mockPrincipalFindFirst.mockResolvedValue({ displayName: 'Author' }) - mockBoardsFindFirst.mockResolvedValue({ id: 'board_mock', slug: 'feedback' }) + mockBoardsFindFirst.mockResolvedValue({ id: 'board_mock', slug: 'feedback', isPublic: true }) // Default: vote count recalculation returns 5 mockDbExecute.mockResolvedValue([{ unique_voters: 5 }]) }) @@ -156,6 +177,18 @@ describe('mergePost', () => { ) }) + it('throws ValidationError when boards have different public visibility', async () => { + mockPostsFindFirst + .mockResolvedValueOnce(mockPost({ id: POST_A, boardId: 'private_board' })) + .mockResolvedValueOnce(mockPost({ id: POST_B, boardId: 'public_board' })) + mockBoardsFindFirst + .mockResolvedValueOnce({ slug: 'internal', isPublic: false }) + .mockResolvedValueOnce({ slug: 'feedback', isPublic: true }) + + await expect(mergePost(POST_A, POST_B, ACTOR)).rejects.toThrow(/same public visibility/) + expect(mockDbUpdate).not.toHaveBeenCalled() + }) + it('records activity on both posts after successful merge', async () => { mockPostsFindFirst .mockResolvedValueOnce(mockPost({ id: POST_A, title: 'Duplicate' })) @@ -215,8 +248,8 @@ describe('mergePost', () => { .mockResolvedValueOnce(mockPost({ id: POST_A, title: 'Dup', boardId: 'board_a' })) .mockResolvedValueOnce(mockPost({ id: POST_B, title: 'Canon', boardId: 'board_b' })) mockBoardsFindFirst - .mockResolvedValueOnce({ slug: 'board-a' }) - .mockResolvedValueOnce({ slug: 'board-b' }) + .mockResolvedValueOnce({ slug: 'board-a', isPublic: true }) + .mockResolvedValueOnce({ slug: 'board-b', isPublic: true }) await mergePost(POST_A, POST_B, ACTOR) @@ -238,10 +271,81 @@ describe('mergePost', () => { }) }) +describe('public merge visibility helpers', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('filters merged-post summaries through public-board visibility', async () => { + const mergedAt = new Date('2026-06-06T12:00:00.000Z') + const selectChain = createSelectChain([ + { + id: POST_A, + title: 'Public duplicate', + voteCount: 4, + authorName: 'Author', + createdAt: new Date('2026-06-05T12:00:00.000Z'), + mergedAt, + }, + ]) + mockDbSelect.mockReturnValueOnce(selectChain) + + const result = await getPublicMergedPosts(POST_B) + + expect(result).toEqual([ + { + id: POST_A, + title: 'Public duplicate', + voteCount: 4, + authorName: 'Author', + createdAt: new Date('2026-06-05T12:00:00.000Z'), + mergedAt, + }, + ]) + expect(selectChain.innerJoin).toHaveBeenCalled() + expect(JSON.stringify(vi.mocked(selectChain.where).mock.calls[0]?.[0])).toContain( + '"left":"is_public","right":true' + ) + }) + + it('hides public merge info when the canonical target is not public or is deleted', async () => { + const mergedAt = new Date('2026-06-06T12:00:00.000Z') + mockDbSelect + .mockReturnValueOnce(createSelectChain([{ canonicalPostId: POST_B, mergedAt }])) + .mockReturnValueOnce(createSelectChain([])) + + await expect(getPublicPostMergeInfo(POST_A)).resolves.toBeNull() + }) + + it('returns public merge info only after both post queries pass public-board filters', async () => { + const mergedAt = new Date('2026-06-06T12:00:00.000Z') + const mergedPostQuery = createSelectChain([{ canonicalPostId: POST_B, mergedAt }]) + const canonicalPostQuery = createSelectChain([ + { id: POST_B, title: 'Canonical public post', boardSlug: 'feedback' }, + ]) + mockDbSelect.mockReturnValueOnce(mergedPostQuery).mockReturnValueOnce(canonicalPostQuery) + + const result = await getPublicPostMergeInfo(POST_A) + + expect(result).toEqual({ + canonicalPostId: POST_B, + canonicalPostTitle: 'Canonical public post', + canonicalPostBoardSlug: 'feedback', + mergedAt, + }) + expect(JSON.stringify(vi.mocked(mergedPostQuery.where).mock.calls[0]?.[0])).toContain( + '"left":"is_public","right":true' + ) + expect(JSON.stringify(vi.mocked(canonicalPostQuery.where).mock.calls[0]?.[0])).toContain( + '"left":"is_public","right":true' + ) + }) +}) + describe('unmergePost', () => { beforeEach(() => { vi.clearAllMocks() - mockBoardsFindFirst.mockResolvedValue({ id: 'board_mock', slug: 'feedback' }) + mockBoardsFindFirst.mockResolvedValue({ id: 'board_mock', slug: 'feedback', isPublic: true }) mockDbExecute.mockResolvedValue([{ unique_voters: 3 }]) }) @@ -301,8 +405,8 @@ describe('unmergePost', () => { ) .mockResolvedValueOnce(mockPost({ id: POST_B, title: 'Canon', boardId: 'board_b' })) mockBoardsFindFirst - .mockResolvedValueOnce({ slug: 'board-a' }) - .mockResolvedValueOnce({ slug: 'board-b' }) + .mockResolvedValueOnce({ slug: 'board-a', isPublic: true }) + .mockResolvedValueOnce({ slug: 'board-b', isPublic: true }) await unmergePost(POST_A, ACTOR) diff --git a/apps/web/src/lib/server/domains/posts/post.merge.ts b/apps/web/src/lib/server/domains/posts/post.merge.ts index 25363fdc3..f9da505de 100644 --- a/apps/web/src/lib/server/domains/posts/post.merge.ts +++ b/apps/web/src/lib/server/domains/posts/post.merge.ts @@ -107,6 +107,28 @@ export async function mergePost( ) } + const [duplicateBoard, canonicalBoard] = await Promise.all([ + db.query.boards.findFirst({ + where: eq(boards.id, duplicatePost.boardId), + columns: { slug: true, isPublic: true }, + }), + db.query.boards.findFirst({ + where: eq(boards.id, canonicalPost.boardId), + columns: { slug: true, isPublic: true }, + }), + ]) + + if (!duplicateBoard || !canonicalBoard) { + throw new NotFoundError('BOARD_NOT_FOUND', 'One or both post boards could not be found') + } + + if (duplicateBoard.isPublic !== canonicalBoard.isPublic) { + throw new ValidationError( + 'INCOMPATIBLE_MERGE_VISIBILITY', + 'Posts can only be merged when their boards have the same public visibility.' + ) + } + // Mark the duplicate post as merged await db .update(posts) @@ -153,33 +175,21 @@ export async function mergePost( }) // Dispatch post.merged event for webhooks and integrations - const [dupBoard, canBoard] = await Promise.all([ - db.query.boards.findFirst({ - where: eq(boards.id, duplicatePost.boardId), - columns: { slug: true }, - }), - db.query.boards.findFirst({ - where: eq(boards.id, canonicalPost.boardId), - columns: { slug: true }, - }), - ]) - if (dupBoard && canBoard) { - dispatchPostMerged( - buildEventActor({ principalId: actorPrincipalId, userId: actorUserId }), - { - id: duplicatePostId, - title: duplicatePost.title, - boardId: duplicatePost.boardId, - boardSlug: dupBoard.slug, - }, - { - id: canonicalPostId, - title: canonicalPost.title, - boardId: canonicalPost.boardId, - boardSlug: canBoard.slug, - } - ) - } + dispatchPostMerged( + buildEventActor({ principalId: actorPrincipalId, userId: actorUserId }), + { + id: duplicatePostId, + title: duplicatePost.title, + boardId: duplicatePost.boardId, + boardSlug: duplicateBoard.slug, + }, + { + id: canonicalPostId, + title: canonicalPost.title, + boardId: canonicalPost.boardId, + boardSlug: canonicalBoard.slug, + } + ) return { canonicalPost: { id: canonicalPostId, voteCount: newVoteCount }, @@ -336,6 +346,46 @@ export async function getMergedPosts(canonicalPostId: PostId): Promise { + const mergedPosts = await db + .select({ + id: posts.id, + title: posts.title, + voteCount: posts.voteCount, + createdAt: posts.createdAt, + mergedAt: posts.mergedAt, + authorName: sql`( + SELECT m.display_name FROM ${principalTable} m + WHERE m.id = ${posts.principalId} + )`.as('author_name'), + }) + .from(posts) + .innerJoin(boards, eq(posts.boardId, boards.id)) + .where( + and( + eq(posts.canonicalPostId, canonicalPostId), + isNull(posts.deletedAt), + eq(boards.isPublic, true) + ) + ) + .orderBy(posts.mergedAt) + + return mergedPosts.map((p) => ({ + id: p.id, + title: p.title, + voteCount: p.voteCount, + authorName: p.authorName, + createdAt: p.createdAt, + mergedAt: p.mergedAt!, + })) +} + /** * Get merge info for a post that has been merged into another. * Returns null if the post is not merged. @@ -376,6 +426,52 @@ export async function getPostMergeInfo(postId: PostId): Promise { + const mergedPost = await db + .select({ + canonicalPostId: posts.canonicalPostId, + mergedAt: posts.mergedAt, + }) + .from(posts) + .innerJoin(boards, eq(posts.boardId, boards.id)) + .where(and(eq(posts.id, postId), isNull(posts.deletedAt), eq(boards.isPublic, true))) + .limit(1) + + const post = mergedPost[0] + if (!post?.canonicalPostId || !post.mergedAt) { + return null + } + + const canonicalPost = await db + .select({ + id: posts.id, + title: posts.title, + boardSlug: boards.slug, + }) + .from(posts) + .innerJoin(boards, eq(posts.boardId, boards.id)) + .where( + and(eq(posts.id, post.canonicalPostId), isNull(posts.deletedAt), eq(boards.isPublic, true)) + ) + .limit(1) + + if (!canonicalPost[0]) { + return null + } + + return { + canonicalPostId: canonicalPost[0].id, + canonicalPostTitle: canonicalPost[0].title, + canonicalPostBoardSlug: canonicalPost[0].boardSlug, + mergedAt: post.mergedAt, + } +} + /** * Result of a merge preview — simulates what the canonical post would look like after merging. */ diff --git a/apps/web/src/lib/server/domains/posts/post.public.detail.ts b/apps/web/src/lib/server/domains/posts/post.public.detail.ts index 397e6b312..e21b8d182 100644 --- a/apps/web/src/lib/server/domains/posts/post.public.detail.ts +++ b/apps/web/src/lib/server/domains/posts/post.public.detail.ts @@ -147,7 +147,10 @@ export async function getPublicPostDetail( SELECT ${postUuid}::uuid UNION ALL SELECT p.id FROM ${posts} p - WHERE p.canonical_post_id = ${postUuid}::uuid AND p.deleted_at IS NULL + INNER JOIN ${boards} b ON b.id = p.board_id + WHERE p.canonical_post_id = ${postUuid}::uuid + AND p.deleted_at IS NULL + AND b.is_public = true ) ${options?.includePrivateComments ? sql`` : sql`AND c.is_private = false`} GROUP BY c.id, m.display_name, m.avatar_key, m.avatar_url, scf.name, scf.color, sct.name, sct.color diff --git a/apps/web/src/lib/server/functions/portal.ts b/apps/web/src/lib/server/functions/portal.ts index 7b713ddd5..cc2094421 100644 --- a/apps/web/src/lib/server/functions/portal.ts +++ b/apps/web/src/lib/server/functions/portal.ts @@ -25,7 +25,7 @@ import { getVotedPostIdsByUserId, } from '@/lib/server/domains/posts/post.public' import { getPublicPostDetail } from '@/lib/server/domains/posts/post.public.detail' -import { getPostMergeInfo, getMergedPosts } from '@/lib/server/domains/posts/post.merge' +import { getPublicPostMergeInfo, getPublicMergedPosts } from '@/lib/server/domains/posts/post.merge' import { listPublicStatuses } from '@/lib/server/domains/statuses/status.service' import { listPublicTags } from '@/lib/server/domains/tags/tag.service' import { getSubscriptionStatus } from '@/lib/server/domains/subscriptions/subscription.service' @@ -202,10 +202,10 @@ export const fetchPublicPostDetail = createServerFn({ method: 'GET' }) // Fetch merge info for this post const postId = data.postId as PostId const [mergeInfo, mergedPostsList] = await Promise.all([ - getPostMergeInfo(postId).then((info) => + getPublicPostMergeInfo(postId).then((info) => info ? { ...info, mergedAt: toISOString(info.mergedAt) } : null ), - getMergedPosts(postId), + getPublicMergedPosts(postId), ]) return {