Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 115 additions & 11 deletions apps/web/src/lib/server/domains/posts/__tests__/post-merge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -21,6 +22,24 @@ function createUpdateChain() {
return chain
}

type SelectChain = {
from: ReturnType<typeof vi.fn>
innerJoin: ReturnType<typeof vi.fn>
where: ReturnType<typeof vi.fn>
orderBy: ReturnType<typeof vi.fn>
limit: ReturnType<typeof vi.fn>
}

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<typeof import('drizzle-orm')>('drizzle-orm')

Expand All @@ -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()
Expand All @@ -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,
}
})
Expand Down Expand Up @@ -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
Expand All @@ -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 }])
})
Expand Down Expand Up @@ -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' }))
Expand Down Expand Up @@ -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)

Expand All @@ -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 }])
})

Expand Down Expand Up @@ -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)

Expand Down
150 changes: 123 additions & 27 deletions apps/web/src/lib/server/domains/posts/post.merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -336,6 +346,46 @@ export async function getMergedPosts(canonicalPostId: PostId): Promise<MergedPos
}))
}

/**
* Get public-board posts merged into a canonical post.
*
* Public portal responses must not expose private/internal-board merged post
* metadata, even if an older cross-visibility merge exists.
*/
export async function getPublicMergedPosts(canonicalPostId: PostId): Promise<MergedPostSummary[]> {
const mergedPosts = await db
Comment thread
BunsDev marked this conversation as resolved.
.select({
id: posts.id,
title: posts.title,
voteCount: posts.voteCount,
createdAt: posts.createdAt,
mergedAt: posts.mergedAt,
authorName: sql<string | null>`(
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.
Expand Down Expand Up @@ -376,6 +426,52 @@ export async function getPostMergeInfo(postId: PostId): Promise<PostMergeInfo |
}
}

/**
* Get merge info for a public portal post without exposing private canonical
* metadata. Returns null unless both the merged post and canonical target are
* on public boards and not deleted.
*/
export async function getPublicPostMergeInfo(postId: PostId): Promise<PostMergeInfo | null> {
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)))
Comment thread
BunsDev marked this conversation as resolved.
.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.
*/
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/lib/server/domains/posts/post.public.detail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading