diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 271882113..d5953f02c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,11 +21,11 @@ jobs: # Build the widget bundle first — apps/web imports # packages/widget/dist/browser.js via Vite ?raw, so it must exist # before the web build runs. - - run: bun run --filter @quackback/widget build + - run: bun run --filter @opencoven/feedback-widget build - run: bun run build env: SKIP_ENV_VALIDATION: true - - run: bun run --filter @quackback/web typecheck + - run: bun run --filter @opencoven-feedback/web typecheck test: runs-on: ubuntu-latest diff --git a/apps/web/src/lib/server/domains/api/__tests__/portal-auth.test.ts b/apps/web/src/lib/server/domains/api/__tests__/portal-auth.test.ts new file mode 100644 index 000000000..4d0048f84 --- /dev/null +++ b/apps/web/src/lib/server/domains/api/__tests__/portal-auth.test.ts @@ -0,0 +1,190 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { PrincipalId, UserId } from '@opencoven-feedback/ids' + +const mockSessionFindFirst = vi.fn() +const mockPrincipalFindFirst = vi.fn() +const mockInsertPrincipal = vi.fn() +const mockGenerateId = vi.fn() + +vi.mock('@/lib/server/db', () => ({ + db: { + query: { + session: { findFirst: (...args: unknown[]) => mockSessionFindFirst(...args) }, + principal: { findFirst: (...args: unknown[]) => mockPrincipalFindFirst(...args) }, + }, + insert: () => ({ + values: () => ({ + returning: () => mockInsertPrincipal(), + }), + }), + }, + session: { token: 'token', expiresAt: 'expires_at' }, + principal: { userId: 'user_id' }, + eq: vi.fn(), + and: vi.fn(), + gt: vi.fn(), +})) + +vi.mock('@opencoven-feedback/ids', () => ({ + generateId: (...args: unknown[]) => mockGenerateId(...args), +})) + +import { optionalPortalSession, requirePortalSession } from '../portal-auth' +import { UnauthorizedError } from '@/lib/shared/errors' + +const USER_ID = 'user_abc123' as unknown as UserId +const PRINCIPAL_ID = 'principal_01kqhxq697fvgat0fn8rr1r7ew' as unknown as PrincipalId + +const sessionRow = { + token: 'tok_valid', + expiresAt: new Date(Date.now() + 3600_000), + userId: USER_ID, + user: { + id: USER_ID, + email: 'alice@example.com', + name: 'Alice', + image: 'https://example.com/avatar.png', + }, +} + +const principalRow = { + id: PRINCIPAL_ID, + userId: USER_ID, + role: 'user', + type: 'user', + displayName: 'Alice', + avatarUrl: null, +} + +function makeRequest(headers: Record = {}): Request { + return new Request('http://test/api/public/v1/test', { headers }) +} + +describe('optionalPortalSession', () => { + beforeEach(() => { + mockSessionFindFirst.mockReset() + mockPrincipalFindFirst.mockReset() + mockInsertPrincipal.mockReset() + mockGenerateId.mockReset() + }) + + it('returns null when no authorization header is present', async () => { + const result = await optionalPortalSession(makeRequest()) + expect(result).toBeNull() + expect(mockSessionFindFirst).not.toHaveBeenCalled() + }) + + it('returns null when authorization header is not a Bearer token', async () => { + const result = await optionalPortalSession(makeRequest({ authorization: 'Basic dXNlcjpwYXNz' })) + expect(result).toBeNull() + expect(mockSessionFindFirst).not.toHaveBeenCalled() + }) + + it('returns null when session lookup returns undefined', async () => { + mockSessionFindFirst.mockResolvedValue(undefined) + const result = await optionalPortalSession(makeRequest({ authorization: 'Bearer tok_missing' })) + expect(result).toBeNull() + }) + + it('returns null when session row has no user', async () => { + mockSessionFindFirst.mockResolvedValue({ ...sessionRow, user: null }) + const result = await optionalPortalSession(makeRequest({ authorization: 'Bearer tok_valid' })) + expect(result).toBeNull() + }) + + it('returns null when session user has a null email', async () => { + mockSessionFindFirst.mockResolvedValue({ + ...sessionRow, + user: { ...sessionRow.user, email: null }, + }) + const result = await optionalPortalSession(makeRequest({ authorization: 'Bearer tok_valid' })) + expect(result).toBeNull() + }) + + it('returns null when session user has an empty string email', async () => { + mockSessionFindFirst.mockResolvedValue({ + ...sessionRow, + user: { ...sessionRow.user, email: '' }, + }) + const result = await optionalPortalSession(makeRequest({ authorization: 'Bearer tok_valid' })) + expect(result).toBeNull() + }) + + it('returns null when insert returning() yields an empty array', async () => { + mockSessionFindFirst.mockResolvedValue(sessionRow) + mockPrincipalFindFirst.mockResolvedValue(null) + mockGenerateId.mockReturnValue('principal_new_01') + mockInsertPrincipal.mockResolvedValue([]) + + const result = await optionalPortalSession(makeRequest({ authorization: 'Bearer tok_valid' })) + expect(result).toBeNull() + }) + + it('returns user + principal when session is valid and principal exists', async () => { + mockSessionFindFirst.mockResolvedValue(sessionRow) + mockPrincipalFindFirst.mockResolvedValue(principalRow) + + const result = await optionalPortalSession(makeRequest({ authorization: 'Bearer tok_valid' })) + + expect(result).not.toBeNull() + expect(result?.user.id).toBe(USER_ID) + expect(result?.user.email).toBe('alice@example.com') + expect(result?.user.name).toBe('Alice') + expect(result?.user.image).toBe('https://example.com/avatar.png') + expect(result?.principal.id).toBe(PRINCIPAL_ID) + expect(result?.principal.role).toBe('user') + expect(result?.principal.type).toBe('user') + }) + + it('inserts a principal and returns it when no principal exists for the user', async () => { + const newPrincipalId = 'principal_new_01' as unknown as PrincipalId + const newPrincipalRow = { + id: newPrincipalId, + userId: USER_ID, + role: 'user', + type: 'user', + displayName: 'Alice', + avatarUrl: null, + } + mockSessionFindFirst.mockResolvedValue(sessionRow) + mockPrincipalFindFirst.mockResolvedValue(null) + mockGenerateId.mockReturnValue(newPrincipalId) + mockInsertPrincipal.mockResolvedValue([newPrincipalRow]) + + const result = await optionalPortalSession(makeRequest({ authorization: 'Bearer tok_valid' })) + + expect(mockGenerateId).toHaveBeenCalledWith('principal') + expect(mockInsertPrincipal).toHaveBeenCalled() + expect(result?.principal.id).toBe(newPrincipalId) + expect(result?.principal.role).toBe('user') + }) +}) + +describe('requirePortalSession', () => { + beforeEach(() => { + mockSessionFindFirst.mockReset() + mockPrincipalFindFirst.mockReset() + mockInsertPrincipal.mockReset() + mockGenerateId.mockReset() + }) + + it('throws UnauthorizedError when no authorization header', async () => { + await expect(requirePortalSession(makeRequest())).rejects.toThrow(UnauthorizedError) + }) + + it('throws UnauthorizedError when token is invalid (no session found)', async () => { + mockSessionFindFirst.mockResolvedValue(undefined) + await expect( + requirePortalSession(makeRequest({ authorization: 'Bearer tok_bad' })) + ).rejects.toThrow(UnauthorizedError) + }) + + it('returns session when valid', async () => { + mockSessionFindFirst.mockResolvedValue(sessionRow) + mockPrincipalFindFirst.mockResolvedValue(principalRow) + + const result = await requirePortalSession(makeRequest({ authorization: 'Bearer tok_valid' })) + expect(result.user.email).toBe('alice@example.com') + expect(result.principal.role).toBe('user') + }) +}) diff --git a/apps/web/src/lib/server/domains/api/portal-auth.ts b/apps/web/src/lib/server/domains/api/portal-auth.ts new file mode 100644 index 000000000..a69cd6084 --- /dev/null +++ b/apps/web/src/lib/server/domains/api/portal-auth.ts @@ -0,0 +1,74 @@ +import type { PrincipalId, UserId } from '@opencoven-feedback/ids' +import { generateId } from '@opencoven-feedback/ids' +import type { Role } from '@/lib/server/auth' +import { db, session, principal, eq, and, gt } from '@/lib/server/db' +import { UnauthorizedError } from '@/lib/shared/errors' + +export interface PortalSession { + user: { id: UserId; email: string; name: string; image: string | null } + principal: { id: PrincipalId; role: Role; type: string } +} + +/** Resolves a portal session from an `Authorization: Bearer ` header. Returns null if absent/invalid/expired. */ +export async function optionalPortalSession(request: Request): Promise { + const authHeader = request.headers.get('authorization') + if (!authHeader?.startsWith('Bearer ')) return null + + const token = authHeader.slice(7) + if (!token) return null + + const row = await db.query.session.findFirst({ + where: and(eq(session.token, token), gt(session.expiresAt, new Date())), + with: { user: true }, + }) + + if (!row?.user) return null + if (!row.user.email) return null + + const userId = row.userId as UserId + + let principalRecord = await db.query.principal.findFirst({ + where: eq(principal.userId, userId), + }) + + if (!principalRecord) { + const [created] = await db + .insert(principal) + .values({ + id: generateId('principal'), + userId, + role: 'user', + displayName: row.user.name, + avatarUrl: row.user.image ?? null, + createdAt: new Date(), + }) + .returning() + if (!created) return null + principalRecord = created + } + + return { + user: { + id: userId, + email: row.user.email, + name: row.user.name, + image: row.user.image ?? null, + }, + principal: { + id: principalRecord.id as PrincipalId, + role: principalRecord.role as Role, + type: principalRecord.type ?? 'user', + }, + } +} + +/** Same as `optionalPortalSession` but throws `UnauthorizedError` when there is no valid session. */ +export async function requirePortalSession(request: Request): Promise { + const portalSession = await optionalPortalSession(request) + if (!portalSession) { + throw new UnauthorizedError( + 'Authentication required. Provide a valid session token in the Authorization header: Bearer ' + ) + } + return portalSession +} diff --git a/apps/web/src/lib/server/domains/api/public-openapi.ts b/apps/web/src/lib/server/domains/api/public-openapi.ts new file mode 100644 index 000000000..0b4063ef4 --- /dev/null +++ b/apps/web/src/lib/server/domains/api/public-openapi.ts @@ -0,0 +1,235 @@ +import { createDocument } from 'zod-openapi' + +/** + * Builds the OpenAPI 3.1 document for the public end-user API. + */ +export function buildPublicOpenApiDocument(baseUrl: string): ReturnType { + return createDocument({ + openapi: '3.1.0', + info: { + title: 'OpenCoven Feedback – Public End-User API', + version: '1.0.0', + description: + 'Public REST API used by the embeddable widget and first-party clients. ' + + 'Authenticated endpoints require a better-auth session token passed as `Authorization: Bearer `.', + }, + servers: [ + { + url: baseUrl, + description: 'Current deployment', + }, + ], + tags: [ + { name: 'Config', description: 'Widget / workspace configuration' }, + { name: 'Boards', description: 'Feedback boards' }, + { name: 'Posts', description: 'Feedback posts' }, + { name: 'Comments', description: 'Post comments' }, + { name: 'Votes', description: 'Post votes' }, + { name: 'Changelog', description: 'Changelog entries' }, + { name: 'Help', description: 'Help-center categories, articles, and search' }, + ], + paths: { + '/api/public/v1/config': { + get: { + operationId: 'getPublicConfig', + summary: 'Get widget configuration', + tags: ['Config'], + responses: { + '200': { description: 'Public widget config' }, + }, + }, + }, + '/api/public/v1/boards': { + get: { + operationId: 'listPublicBoards', + summary: 'List boards', + tags: ['Boards'], + responses: { + '200': { description: 'List of boards' }, + }, + }, + }, + '/api/public/v1/posts': { + get: { + operationId: 'listPublicPosts', + summary: 'List posts', + tags: ['Posts'], + responses: { + '200': { description: 'Paginated list of posts' }, + }, + }, + post: { + operationId: 'createPublicPost', + summary: 'Submit a post', + tags: ['Posts'], + security: [{ bearerAuth: [] }], + responses: { + '201': { description: 'Post created' }, + '401': { description: 'Unauthorized' }, + }, + }, + }, + '/api/public/v1/posts/{postId}': { + get: { + operationId: 'getPublicPost', + summary: 'Get post by ID', + tags: ['Posts'], + parameters: [ + { + name: 'postId', + in: 'path', + required: true, + schema: { type: 'string' }, + }, + ], + responses: { + '200': { description: 'Post detail' }, + '404': { description: 'Post not found' }, + }, + }, + }, + '/api/public/v1/posts/{postId}/comments': { + get: { + operationId: 'listPublicPostComments', + summary: 'List comments on a post', + tags: ['Comments'], + parameters: [ + { + name: 'postId', + in: 'path', + required: true, + schema: { type: 'string' }, + }, + ], + responses: { + '200': { description: 'List of comments' }, + }, + }, + post: { + operationId: 'createPublicPostComment', + summary: 'Add a comment to a post', + tags: ['Comments'], + security: [{ bearerAuth: [] }], + parameters: [ + { + name: 'postId', + in: 'path', + required: true, + schema: { type: 'string' }, + }, + ], + responses: { + '201': { description: 'Comment created' }, + '401': { description: 'Unauthorized' }, + }, + }, + }, + '/api/public/v1/posts/{postId}/vote': { + post: { + operationId: 'togglePublicPostVote', + summary: 'Toggle vote on a post', + tags: ['Votes'], + security: [{ bearerAuth: [] }], + parameters: [ + { + name: 'postId', + in: 'path', + required: true, + schema: { type: 'string' }, + }, + ], + responses: { + '200': { description: 'Vote toggled' }, + '401': { description: 'Unauthorized' }, + }, + }, + }, + '/api/public/v1/changelog': { + get: { + operationId: 'listPublicChangelog', + summary: 'List changelog entries', + tags: ['Changelog'], + responses: { + '200': { description: 'Paginated list of changelog entries' }, + }, + }, + }, + '/api/public/v1/changelog/{entryId}': { + get: { + operationId: 'getPublicChangelogEntry', + summary: 'Get changelog entry by ID', + tags: ['Changelog'], + parameters: [ + { + name: 'entryId', + in: 'path', + required: true, + schema: { type: 'string' }, + }, + ], + responses: { + '200': { description: 'Changelog entry' }, + '404': { description: 'Entry not found' }, + }, + }, + }, + '/api/public/v1/help/categories': { + get: { + operationId: 'listHelpCategories', + summary: 'List help-center categories', + tags: ['Help'], + responses: { + '200': { description: 'List of categories' }, + }, + }, + }, + '/api/public/v1/help/articles/{slug}': { + get: { + operationId: 'getHelpArticle', + summary: 'Get help article by slug', + tags: ['Help'], + parameters: [ + { + name: 'slug', + in: 'path', + required: true, + schema: { type: 'string' }, + }, + ], + responses: { + '200': { description: 'Help article' }, + '404': { description: 'Article not found' }, + }, + }, + }, + '/api/public/v1/help/search': { + get: { + operationId: 'searchHelp', + summary: 'Search help-center articles', + tags: ['Help'], + parameters: [ + { + name: 'q', + in: 'query', + required: true, + schema: { type: 'string' }, + description: 'Search query', + }, + ], + responses: { + '200': { description: 'Search results' }, + }, + }, + }, + }, + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + description: 'better-auth session token', + }, + }, + }, + }) +} diff --git a/apps/web/src/lib/server/domains/posts/__tests__/post.public-list.test.ts b/apps/web/src/lib/server/domains/posts/__tests__/post.public-list.test.ts new file mode 100644 index 000000000..bdc308ae4 --- /dev/null +++ b/apps/web/src/lib/server/domains/posts/__tests__/post.public-list.test.ts @@ -0,0 +1,290 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { BoardId } from '@opencoven-feedback/ids' + +// ── Drizzle operator mocks ────────────────────────────────────────────────── +const mockEq = vi.fn((col, val) => ({ _tag: 'eq', col, val })) +const mockIsNull = vi.fn((col) => ({ _tag: 'isNull', col })) +const mockAnd = vi.fn((...args: unknown[]) => ({ _tag: 'and', args })) +const mockDesc = vi.fn((col) => ({ _tag: 'desc', col })) +const mockSql = vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ + _tag: 'sql', + strings, + values, +})) + +// ── Schema symbol mocks ───────────────────────────────────────────────────── +const mockPosts = { + id: Symbol('posts.id'), + boardId: Symbol('posts.boardId'), + title: Symbol('posts.title'), + voteCount: Symbol('posts.voteCount'), + statusId: Symbol('posts.statusId'), + createdAt: Symbol('posts.createdAt'), + deletedAt: Symbol('posts.deletedAt'), + canonicalPostId: Symbol('posts.canonicalPostId'), +} + +const mockBoards = { + isPublic: Symbol('boards.isPublic'), + id: Symbol('boards.id'), + slug: Symbol('boards.slug'), + name: Symbol('boards.name'), + deletedAt: Symbol('boards.deletedAt'), +} + +// ── Core query builder chain mock ────────────────────────────────────────── +// db.select({}).from(posts).innerJoin(boards,...).where(...).orderBy(...).limit(N) +const mockLimit = vi.fn().mockResolvedValue([]) +const mockOrderBy = vi.fn().mockReturnValue({ limit: mockLimit }) +const mockWhere = vi.fn().mockReturnValue({ orderBy: mockOrderBy }) +const mockInnerJoin = vi.fn().mockReturnValue({ where: mockWhere }) +const mockFrom = vi.fn().mockReturnValue({ innerJoin: mockInnerJoin }) +const mockSelect = vi.fn().mockReturnValue({ from: mockFrom }) + +// ── db.query mock (used only for cursor resolution) ───────────────────────── +const mockPostsFindFirst = vi.fn() + +vi.mock('@/lib/server/db', () => ({ + db: { + select: (...args: unknown[]) => mockSelect(...args), + query: { + posts: { + findFirst: (...args: unknown[]) => mockPostsFindFirst(...args), + }, + }, + }, + eq: mockEq, + and: mockAnd, + isNull: mockIsNull, + desc: mockDesc, + sql: mockSql, + posts: mockPosts, + boards: mockBoards, +})) + +// ── helpers ───────────────────────────────────────────────────────────────── +function makePost( + overrides: Partial<{ + id: string + title: string + voteCount: number + statusId: string | null + boardId: string + createdAt: Date + }> = {} +) { + return { + id: overrides.id ?? 'post_01', + title: overrides.title ?? 'A post', + voteCount: overrides.voteCount ?? 3, + statusId: overrides.statusId ?? null, + boardId: overrides.boardId ?? 'board_01', + createdAt: overrides.createdAt ?? new Date('2026-01-01T00:00:00Z'), + } +} + +// ── tests ─────────────────────────────────────────────────────────────────── +describe('listPublicPostFeed', () => { + beforeEach(() => { + vi.clearAllMocks() + mockLimit.mockResolvedValue([]) + mockOrderBy.mockReturnValue({ limit: mockLimit }) + mockWhere.mockReturnValue({ orderBy: mockOrderBy }) + mockInnerJoin.mockReturnValue({ where: mockWhere }) + mockFrom.mockReturnValue({ innerJoin: mockInnerJoin }) + mockSelect.mockReturnValue({ from: mockFrom }) + mockPostsFindFirst.mockResolvedValue(null) + }) + + it('uses innerJoin(boards, eq(posts.boardId, boards.id))', async () => { + const { listPublicPostFeed } = await import('../post.public-list') + await listPublicPostFeed({ limit: 10 }) + + expect(mockInnerJoin).toHaveBeenCalledWith(mockBoards, expect.objectContaining({ _tag: 'eq' })) + // The join condition must be eq(posts.boardId, boards.id) + expect(mockEq).toHaveBeenCalledWith(mockPosts.boardId, mockBoards.id) + }) + + it('filters to public boards (eq boards.isPublic, true)', async () => { + const { listPublicPostFeed } = await import('../post.public-list') + await listPublicPostFeed({ limit: 10 }) + + expect(mockEq).toHaveBeenCalledWith(mockBoards.isPublic, true) + }) + + it('filters out soft-deleted boards (isNull boards.deletedAt)', async () => { + const { listPublicPostFeed } = await import('../post.public-list') + await listPublicPostFeed({ limit: 10 }) + + expect(mockIsNull).toHaveBeenCalledWith(mockBoards.deletedAt) + }) + + it('filters out soft-deleted posts (isNull posts.deletedAt)', async () => { + const { listPublicPostFeed } = await import('../post.public-list') + await listPublicPostFeed({ limit: 10 }) + + expect(mockIsNull).toHaveBeenCalledWith(mockPosts.deletedAt) + }) + + it('filters out merged posts (isNull canonicalPostId)', async () => { + const { listPublicPostFeed } = await import('../post.public-list') + await listPublicPostFeed({ limit: 10 }) + + expect(mockIsNull).toHaveBeenCalledWith(mockPosts.canonicalPostId) + }) + + it('applies boardId filter when provided', async () => { + const { listPublicPostFeed } = await import('../post.public-list') + await listPublicPostFeed({ boardId: 'board_01' as BoardId, limit: 10 }) + + expect(mockEq).toHaveBeenCalledWith(mockPosts.boardId, 'board_01') + }) + + it('does not apply boardId filter when omitted', async () => { + const { listPublicPostFeed } = await import('../post.public-list') + await listPublicPostFeed({ limit: 10 }) + + const boardIdCalls = mockEq.mock.calls.filter(([col]) => col === mockPosts.boardId) + // Only the join condition uses posts.boardId; the filter boardId eq must not appear + const boardIdFilterCalls = boardIdCalls.filter(([, val]) => val !== mockBoards.id) + expect(boardIdFilterCalls).toHaveLength(0) + }) + + it('orders by desc(createdAt) for sort=newest', async () => { + const { listPublicPostFeed } = await import('../post.public-list') + await listPublicPostFeed({ sort: 'newest', limit: 10 }) + + expect(mockDesc).toHaveBeenCalledWith(mockPosts.createdAt) + }) + + it('orders by desc(voteCount) for sort=votes', async () => { + const { listPublicPostFeed } = await import('../post.public-list') + await listPublicPostFeed({ sort: 'votes', limit: 10 }) + + expect(mockDesc).toHaveBeenCalledWith(mockPosts.voteCount) + }) + + it('defaults to newest sort when sort is omitted', async () => { + const { listPublicPostFeed } = await import('../post.public-list') + await listPublicPostFeed({ limit: 10 }) + + expect(mockDesc).toHaveBeenCalledWith(mockPosts.createdAt) + }) + + it('maps returned rows to PublicPostFeedSummary with ISO createdAt string', async () => { + const post = makePost({ id: 'post_01', title: 'Hello', voteCount: 5, statusId: 'status_01' }) + mockLimit.mockResolvedValue([post]) + + const { listPublicPostFeed } = await import('../post.public-list') + const result = await listPublicPostFeed({ limit: 10 }) + + expect(result.items).toHaveLength(1) + const item = result.items[0] + expect(item.id).toBe('post_01') + expect(item.title).toBe('Hello') + expect(item.voteCount).toBe(5) + expect(item.statusId).toBe('status_01') + expect(item.boardId).toBe('board_01') + expect(typeof item.createdAt).toBe('string') + expect(item.createdAt).toBe('2026-01-01T00:00:00.000Z') + }) + + it('hasMore=false and cursor=null when results <= limit', async () => { + mockLimit.mockResolvedValue([makePost()]) + + const { listPublicPostFeed } = await import('../post.public-list') + const result = await listPublicPostFeed({ limit: 10 }) + + expect(result.hasMore).toBe(false) + expect(result.cursor).toBeNull() + }) + + it('hasMore=true and cursor=lastItemId when results > limit (limit+1 trick)', async () => { + // limit=2, return 3 items → hasMore true, cursor = id of 2nd item + const fakePosts = [ + makePost({ id: 'post_01' }), + makePost({ id: 'post_02' }), + makePost({ id: 'post_03' }), + ] + mockLimit.mockResolvedValue(fakePosts) + + const { listPublicPostFeed } = await import('../post.public-list') + const result = await listPublicPostFeed({ limit: 2 }) + + expect(result.hasMore).toBe(true) + expect(result.items).toHaveLength(2) + expect(result.cursor).toBe('post_02') + }) + + it('passes limit+1 to the query', async () => { + const { listPublicPostFeed } = await import('../post.public-list') + await listPublicPostFeed({ limit: 5 }) + + expect(mockLimit).toHaveBeenCalledWith(6) + }) + + it('resolves cursor to keyset condition for sort=newest', async () => { + const validId = 'post_01kssa4ttmf68rwn8jn633yxp3' + const cursorPost = makePost({ id: validId, createdAt: new Date('2026-03-01T00:00:00Z') }) + mockPostsFindFirst.mockResolvedValue(cursorPost) + mockLimit.mockResolvedValue([]) + + const { listPublicPostFeed } = await import('../post.public-list') + await listPublicPostFeed({ sort: 'newest', cursor: validId, limit: 10 }) + + expect(mockPostsFindFirst).toHaveBeenCalled() + expect(mockSql).toHaveBeenCalled() + }) + + it('resolves cursor to keyset condition for sort=votes', async () => { + const validId = 'post_01kssa4ttmf68rwn8tsfp3m1e2' + const cursorPost = makePost({ + id: validId, + voteCount: 7, + createdAt: new Date('2026-03-01T00:00:00Z'), + }) + mockPostsFindFirst.mockResolvedValue(cursorPost) + mockLimit.mockResolvedValue([]) + + const { listPublicPostFeed } = await import('../post.public-list') + await listPublicPostFeed({ sort: 'votes', cursor: validId, limit: 10 }) + + expect(mockPostsFindFirst).toHaveBeenCalled() + expect(mockSql).toHaveBeenCalled() + }) + + it('does not call findFirst when no cursor provided', async () => { + const { listPublicPostFeed } = await import('../post.public-list') + await listPublicPostFeed({ limit: 10 }) + + expect(mockPostsFindFirst).not.toHaveBeenCalled() + }) + + // I1 — cursor anchor query must include isNull(posts.deletedAt) + it('cursor anchor query includes isNull(posts.deletedAt) guard (I1)', async () => { + // Reuse a valid TypeID from sibling tests so toUuid() does not throw + const validId = 'post_01kssa4ttmf68rwn8jn633yxp3' + const cursorPost = makePost({ id: validId, createdAt: new Date('2026-03-01T00:00:00Z') }) + mockPostsFindFirst.mockResolvedValue(cursorPost) + mockLimit.mockResolvedValue([]) + + const { listPublicPostFeed } = await import('../post.public-list') + await listPublicPostFeed({ sort: 'newest', cursor: validId, limit: 10 }) + + // The cursor findFirst call must filter by isNull(posts.deletedAt) + expect(mockPostsFindFirst).toHaveBeenCalled() + const findFirstArg = mockPostsFindFirst.mock.calls[0][0] + // where should be an and(...) combining eq(posts.id, ...) and isNull(posts.deletedAt) + expect(findFirstArg.where).toMatchObject({ _tag: 'and' }) + const andArgs = findFirstArg.where.args as unknown[] + const hasDeletedAtGuard = andArgs.some( + (a) => + typeof a === 'object' && + a !== null && + '_tag' in a && + (a as { _tag: string })._tag === 'isNull' + ) + expect(hasDeletedAtGuard).toBe(true) + expect(mockIsNull).toHaveBeenCalledWith(mockPosts.deletedAt) + }) +}) diff --git a/apps/web/src/lib/server/domains/posts/post.public-list.ts b/apps/web/src/lib/server/domains/posts/post.public-list.ts new file mode 100644 index 000000000..8fcf9ccec --- /dev/null +++ b/apps/web/src/lib/server/domains/posts/post.public-list.ts @@ -0,0 +1,110 @@ +/** + * Public post feed query for the anonymous feed (cursor-keyset paginated). + * + * Uses the core query builder with an explicit INNER JOIN on boards so that + * the boards.isPublic / boards.deletedAt filters are applied correctly. + * The relational API (db.query.*) re-aliases joined columns to the outer + * table, which would silently drop or misapply cross-table WHERE conditions. + */ + +import { db, eq, and, isNull, desc, sql, posts, boards } from '@/lib/server/db' +import { toUuid, type PostId, type BoardId, type StatusId } from '@opencoven-feedback/ids' + +export interface PublicPostFeedSummary { + id: PostId + title: string + voteCount: number + statusId: StatusId | null + boardId: BoardId + createdAt: string // ISO-8601 +} + +export interface ListPublicPostFeedParams { + boardId?: BoardId + sort?: 'newest' | 'votes' + cursor?: string + limit: number +} + +export interface ListPublicPostFeedResult { + items: PublicPostFeedSummary[] + cursor: string | null + hasMore: boolean +} + +export async function listPublicPostFeed( + params: ListPublicPostFeedParams +): Promise { + const { boardId, sort = 'newest', cursor, limit } = params + + // Base conditions: public board, board not soft-deleted, post not soft-deleted, not merged + const conditions = [ + eq(boards.isPublic, true), + isNull(boards.deletedAt), + isNull(posts.deletedAt), + isNull(posts.canonicalPostId), + ] + + if (boardId) { + conditions.push(eq(posts.boardId, boardId)) + } + + // Cursor-based keyset pagination + if (cursor) { + const cursorPost = await db.query.posts.findFirst({ + where: and(eq(posts.id, cursor as PostId), isNull(posts.deletedAt)), + columns: { id: true, createdAt: true, voteCount: true }, + }) + if (cursorPost) { + const cursorDate = cursorPost.createdAt.toISOString() + const cursorUuid = toUuid(cursorPost.id) + if (sort === 'votes') { + conditions.push( + sql`(${posts.voteCount}, ${posts.createdAt}, ${posts.id}) < (${cursorPost.voteCount}, ${cursorDate}, ${cursorUuid}::uuid)` + ) + } else { + // newest (default) + conditions.push( + sql`(${posts.createdAt}, ${posts.id}) < (${cursorDate}, ${cursorUuid}::uuid)` + ) + } + } + } + + const orderByMap = { + newest: [desc(posts.createdAt), desc(posts.id)], + votes: [desc(posts.voteCount), desc(posts.createdAt), desc(posts.id)], + } + + const rawPosts = await db + .select({ + id: posts.id, + title: posts.title, + voteCount: posts.voteCount, + statusId: posts.statusId, + boardId: posts.boardId, + createdAt: posts.createdAt, + }) + .from(posts) + .innerJoin(boards, eq(posts.boardId, boards.id)) + .where(and(...conditions)) + .orderBy(...orderByMap[sort]) + .limit(limit + 1) + + const hasMore = rawPosts.length > limit + const sliced = hasMore ? rawPosts.slice(0, limit) : rawPosts + + const items: PublicPostFeedSummary[] = sliced.map((post) => ({ + id: post.id, + title: post.title, + voteCount: post.voteCount, + statusId: post.statusId, + boardId: post.boardId, + createdAt: post.createdAt.toISOString(), + })) + + const lastItem = items[items.length - 1] + const nextCursor = hasMore && lastItem ? lastItem.id : null + + return { items, cursor: nextCursor, hasMore } +} diff --git a/apps/web/src/lib/server/domains/posts/post.query.ts b/apps/web/src/lib/server/domains/posts/post.query.ts index bfe7dcbdd..779e3caff 100644 --- a/apps/web/src/lib/server/domains/posts/post.query.ts +++ b/apps/web/src/lib/server/domains/posts/post.query.ts @@ -151,6 +151,7 @@ export async function getPostWithDetails(postId: PostId): Promise ({ id: t.id, @@ -175,7 +176,8 @@ export async function getPostWithDetails(postId: PostId): Promise { // Verify post exists and belongs to organization const post = await db.query.posts.findFirst({ where: eq(posts.id, postId) }) @@ -195,9 +197,16 @@ export async function getCommentsWithReplies( }) const postIds = [postId, ...mergedPosts.map((p) => p.id)] as PostId[] + // Build the where clause for comments + const basePostWhere = + postIds.length === 1 ? eq(comments.postId, postId) : inArray(comments.postId, postIds) + const commentsWhere = opts?.publicOnly + ? and(basePostWhere, eq(comments.isPrivate, false)) + : basePostWhere + // Get all comments with reactions, author info, and status changes (including from merged posts) const allComments = await db.query.comments.findMany({ - where: postIds.length === 1 ? eq(comments.postId, postId) : inArray(comments.postId, postIds), + where: commentsWhere, with: { reactions: true, author: { @@ -220,5 +229,7 @@ export async function getCommentsWithReplies( statusChange: toStatusChange(c.statusChangeFrom, c.statusChangeTo), })) - return buildCommentTree(commentsWithAuthor, principalId) + return buildCommentTree(commentsWithAuthor, principalId, { + pruneDeleted: opts?.publicOnly ?? false, + }) } diff --git a/apps/web/src/lib/server/domains/posts/post.types.ts b/apps/web/src/lib/server/domains/posts/post.types.ts index 228e2751d..908b3c06f 100644 --- a/apps/web/src/lib/server/domains/posts/post.types.ts +++ b/apps/web/src/lib/server/domains/posts/post.types.ts @@ -66,6 +66,7 @@ export interface PostWithDetails extends Post { id: BoardId name: string slug: string + isPublic: boolean } tags: Array<{ id: TagId diff --git a/apps/web/src/routes/api/public/v1/__tests__/config.test.ts b/apps/web/src/routes/api/public/v1/__tests__/config.test.ts new file mode 100644 index 000000000..8baeb25a8 --- /dev/null +++ b/apps/web/src/routes/api/public/v1/__tests__/config.test.ts @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockGetPublicWidgetConfig = vi.fn() + +vi.mock('@tanstack/react-router', () => ({ + createFileRoute: vi.fn(() => (opts: unknown) => ({ options: opts })), +})) +vi.mock('@/lib/server/domains/settings/settings.widget', () => ({ + getPublicWidgetConfig: (...args: unknown[]) => mockGetPublicWidgetConfig(...args), +})) + +import { Route } from '../config' + +type RouteOpts = { server: { handlers: { GET: () => Promise } } } +const GET = (Route as unknown as { options: RouteOpts }).options.server.handlers.GET + +const MOCK_CONFIG = { + enabled: true, + defaultBoard: 'board_abc', + position: 'bottom-right', + tabs: ['feedback', 'changelog'], + hmacRequired: false, + imageUploadsInWidget: true, +} + +describe('GET /api/public/v1/config', () => { + beforeEach(() => { + mockGetPublicWidgetConfig.mockReset() + }) + + it('returns 200 with the public widget config in data envelope', async () => { + mockGetPublicWidgetConfig.mockResolvedValue(MOCK_CONFIG) + const res = await GET() + expect(res.status).toBe(200) + const json = await res.json() + expect(json.data).toEqual(MOCK_CONFIG) + }) + + it('delegates errors to handleDomainError', async () => { + mockGetPublicWidgetConfig.mockRejectedValue({ code: 'NOT_FOUND', message: 'not found' }) + const res = await GET() + expect(res.status).toBe(404) + }) +}) diff --git a/apps/web/src/routes/api/public/v1/__tests__/openapi.test.ts b/apps/web/src/routes/api/public/v1/__tests__/openapi.test.ts new file mode 100644 index 000000000..6ef33b063 --- /dev/null +++ b/apps/web/src/routes/api/public/v1/__tests__/openapi.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@tanstack/react-router', () => ({ + createFileRoute: vi.fn(() => (opts: unknown) => ({ options: opts })), +})) + +vi.mock('@/lib/server/config', () => ({ + config: { + baseUrl: 'https://example.com', + }, +})) + +import { Route } from '../openapi.json' + +type RouteOpts = { server: { handlers: { GET: () => Promise } } } +const GET = (Route as unknown as { options: RouteOpts }).options.server.handlers.GET + +describe('GET /api/public/v1/openapi.json', () => { + it('returns 200', async () => { + const res = await GET() + expect(res.status).toBe(200) + }) + + it('returns a valid OpenAPI 3.1 document', async () => { + const res = await GET() + const doc = await res.json() + expect(doc.openapi).toMatch(/^3\./) + }) + + it('includes /api/public/v1/posts in paths', async () => { + const res = await GET() + const doc = await res.json() + expect(Object.keys(doc.paths)).toContain('/api/public/v1/posts') + }) + + it('includes /api/public/v1/posts/{postId}/vote in paths', async () => { + const res = await GET() + const doc = await res.json() + expect(Object.keys(doc.paths)).toContain('/api/public/v1/posts/{postId}/vote') + }) + + it('includes bearerAuth security scheme', async () => { + const res = await GET() + const doc = await res.json() + expect(doc.components?.securitySchemes?.bearerAuth).toBeDefined() + expect(doc.components.securitySchemes.bearerAuth.type).toBe('http') + expect(doc.components.securitySchemes.bearerAuth.scheme).toBe('bearer') + }) + + it('sets correct Content-Type header', async () => { + const res = await GET() + expect(res.headers.get('Content-Type')).toContain('application/json') + }) + + it('sets CORS header', async () => { + const res = await GET() + expect(res.headers.get('Access-Control-Allow-Origin')).toBe('*') + }) +}) diff --git a/apps/web/src/routes/api/public/v1/boards/index.ts b/apps/web/src/routes/api/public/v1/boards/index.ts new file mode 100644 index 000000000..5b5c85b6b --- /dev/null +++ b/apps/web/src/routes/api/public/v1/boards/index.ts @@ -0,0 +1,35 @@ +import { createFileRoute } from '@tanstack/react-router' +import { successResponse, handleDomainError } from '@/lib/server/domains/api/responses' + +export const Route = createFileRoute('/api/public/v1/boards/')({ + server: { + handlers: { + /** + * GET /api/public/v1/boards + * Returns all public boards with post counts. + */ + GET: async () => { + try { + const { listBoardsWithDetails } = + await import('@/lib/server/domains/boards/board.service') + + const boards = await listBoardsWithDetails() + + return successResponse( + boards + .filter((b) => b.isPublic) + .map((b) => ({ + id: b.id, + name: b.name, + slug: b.slug, + description: b.description, + postCount: b.postCount, + })) + ) + } catch (error) { + return handleDomainError(error) + } + }, + }, + }, +}) diff --git a/apps/web/src/routes/api/public/v1/changelog/$entryId.ts b/apps/web/src/routes/api/public/v1/changelog/$entryId.ts new file mode 100644 index 000000000..0bab83d17 --- /dev/null +++ b/apps/web/src/routes/api/public/v1/changelog/$entryId.ts @@ -0,0 +1,34 @@ +import { createFileRoute } from '@tanstack/react-router' +import { successResponse, handleDomainError } from '@/lib/server/domains/api/responses' +import { getChangelogById } from '@/lib/server/domains/changelog/changelog.service' +import { NotFoundError } from '@/lib/shared/errors' +import type { ChangelogId } from '@opencoven-feedback/ids' + +export const Route = createFileRoute('/api/public/v1/changelog/$entryId')({ + server: { + handlers: { + /** + * GET /api/public/v1/changelog/:entryId + * Anonymous read — returns a published changelog entry only. + */ + GET: async ({ params }) => { + try { + const entry = await getChangelogById(params.entryId as ChangelogId) + + if (entry.status !== 'published') { + throw new NotFoundError('CHANGELOG_NOT_FOUND', 'Changelog entry not found') + } + + return successResponse({ + id: entry.id, + title: entry.title, + content: entry.content, + publishedAt: entry.publishedAt?.toISOString() ?? null, + }) + } catch (error) { + return handleDomainError(error) + } + }, + }, + }, +}) diff --git a/apps/web/src/routes/api/public/v1/changelog/__tests__/index.test.ts b/apps/web/src/routes/api/public/v1/changelog/__tests__/index.test.ts new file mode 100644 index 000000000..a426589db --- /dev/null +++ b/apps/web/src/routes/api/public/v1/changelog/__tests__/index.test.ts @@ -0,0 +1,251 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ChangelogId } from '@opencoven-feedback/ids' + +const mockListChangelogs = vi.fn() +const mockGetChangelogById = vi.fn() + +vi.mock('@tanstack/react-router', () => ({ + createFileRoute: vi.fn(() => (opts: unknown) => ({ options: opts })), +})) +vi.mock('@/lib/server/domains/changelog/changelog.query', () => ({ + listChangelogs: (...args: unknown[]) => mockListChangelogs(...args), +})) +vi.mock('@/lib/server/domains/changelog/changelog.service', () => ({ + getChangelogById: (...args: unknown[]) => mockGetChangelogById(...args), +})) + +import { Route as ListRoute } from '../index' +import { Route as EntryRoute } from '../$entryId' + +type ListRouteOpts = { + server: { handlers: { GET: (ctx: { request: Request }) => Promise } } +} +type EntryRouteOpts = { + server: { + handlers: { + GET: (ctx: { request: Request; params: { entryId: string } }) => Promise + } + } +} + +const ListGET = (ListRoute as unknown as { options: ListRouteOpts }).options.server.handlers.GET +const EntryGET = (EntryRoute as unknown as { options: EntryRouteOpts }).options.server.handlers.GET + +const ENTRY_ID_1 = 'changelog_01kqhxq697fvgat0fn8rr1r7ea' as unknown as ChangelogId +const ENTRY_ID_2 = 'changelog_01kqhxq697fvgat0fn8rr1r7eb' as unknown as ChangelogId + +const PUBLISHED_DATE = new Date('2024-03-01T12:00:00.000Z') + +const MOCK_ENTRIES = [ + { + id: ENTRY_ID_1, + title: 'Entry One', + content: 'Content one', + publishedAt: PUBLISHED_DATE, + status: 'published' as const, + createdAt: new Date('2024-03-01T00:00:00.000Z'), + updatedAt: new Date('2024-03-02T00:00:00.000Z'), + }, + { + id: ENTRY_ID_2, + title: 'Entry Two', + content: 'Content two', + publishedAt: new Date('2024-02-01T12:00:00.000Z'), + status: 'published' as const, + createdAt: new Date('2024-02-01T00:00:00.000Z'), + updatedAt: new Date('2024-02-02T00:00:00.000Z'), + }, +] + +function makeRequest(url = 'http://test/api/public/v1/changelog'): Request { + return new Request(url) +} + +// ============================================================= +// GET /api/public/v1/changelog +// ============================================================= +describe('GET /api/public/v1/changelog', () => { + beforeEach(() => { + mockListChangelogs.mockReset() + mockListChangelogs.mockResolvedValue({ + items: MOCK_ENTRIES, + nextCursor: null, + hasMore: false, + }) + }) + + it('always calls listChangelogs with status: published', async () => { + await ListGET({ request: makeRequest() }) + expect(mockListChangelogs).toHaveBeenCalledWith( + expect.objectContaining({ status: 'published' }) + ) + }) + + it('does not accept a status query param — still hardcodes published', async () => { + await ListGET({ request: makeRequest('http://test/api/public/v1/changelog?status=draft') }) + expect(mockListChangelogs).toHaveBeenCalledWith( + expect.objectContaining({ status: 'published' }) + ) + }) + + it('maps items to public subset { id, title, publishedAt }', async () => { + const res = await ListGET({ request: makeRequest() }) + expect(res.status).toBe(200) + const json = await res.json() + expect(json.data).toHaveLength(2) + const item = json.data[0] + expect(item.id).toBe(ENTRY_ID_1) + expect(item.title).toBe('Entry One') + expect(item.publishedAt).toBe(PUBLISHED_DATE.toISOString()) + // private fields must NOT be present + expect(item.content).toBeUndefined() + expect(item.createdAt).toBeUndefined() + expect(item.updatedAt).toBeUndefined() + }) + + it('passes cursor and limit to listChangelogs', async () => { + const url = + 'http://test/api/public/v1/changelog?cursor=changelog_01kqhxq697fvgat0fn8rr1r7ea&limit=10' + await ListGET({ request: makeRequest(url) }) + expect(mockListChangelogs).toHaveBeenCalledWith( + expect.objectContaining({ cursor: 'changelog_01kqhxq697fvgat0fn8rr1r7ea', limit: 10 }) + ) + }) + + it('clamps limit to 100 maximum', async () => { + await ListGET({ request: makeRequest('http://test/api/public/v1/changelog?limit=999') }) + expect(mockListChangelogs).toHaveBeenCalledWith(expect.objectContaining({ limit: 100 })) + }) + + it('clamps limit to 1 minimum', async () => { + await ListGET({ request: makeRequest('http://test/api/public/v1/changelog?limit=0') }) + expect(mockListChangelogs).toHaveBeenCalledWith(expect.objectContaining({ limit: 1 })) + }) + + it('defaults limit to 20', async () => { + await ListGET({ request: makeRequest() }) + expect(mockListChangelogs).toHaveBeenCalledWith(expect.objectContaining({ limit: 20 })) + }) + + it('pagination meta passes through (hasMore, cursor)', async () => { + mockListChangelogs.mockResolvedValue({ + items: MOCK_ENTRIES, + nextCursor: ENTRY_ID_2, + hasMore: true, + }) + const res = await ListGET({ request: makeRequest() }) + expect(res.status).toBe(200) + const json = await res.json() + expect(json.meta.pagination.hasMore).toBe(true) + expect(json.meta.pagination.cursor).toBe(ENTRY_ID_2) + }) + + it('serializes null publishedAt as null', async () => { + mockListChangelogs.mockResolvedValue({ + items: [{ ...MOCK_ENTRIES[0], publishedAt: null }], + nextCursor: null, + hasMore: false, + }) + const res = await ListGET({ request: makeRequest() }) + const json = await res.json() + expect(json.data[0].publishedAt).toBeNull() + }) + + it('delegates errors to handleDomainError', async () => { + mockListChangelogs.mockRejectedValue({ code: 'NOT_FOUND', message: 'not found' }) + const res = await ListGET({ request: makeRequest() }) + expect(res.status).toBe(404) + }) +}) + +// ============================================================= +// GET /api/public/v1/changelog/:entryId +// ============================================================= +describe('GET /api/public/v1/changelog/:entryId', () => { + beforeEach(() => { + mockGetChangelogById.mockReset() + mockGetChangelogById.mockResolvedValue({ + ...MOCK_ENTRIES[0], + status: 'published' as const, + }) + }) + + it('returns 200 with { id, title, content, publishedAt } for published entry', async () => { + const res = await EntryGET({ + request: makeRequest(), + params: { entryId: ENTRY_ID_1 }, + }) + expect(res.status).toBe(200) + const json = await res.json() + expect(json.data.id).toBe(ENTRY_ID_1) + expect(json.data.title).toBe('Entry One') + expect(json.data.content).toBe('Content one') + expect(json.data.publishedAt).toBe(PUBLISHED_DATE.toISOString()) + }) + + it('does not expose admin-only fields', async () => { + const res = await EntryGET({ + request: makeRequest(), + params: { entryId: ENTRY_ID_1 }, + }) + const json = await res.json() + expect(json.data.createdAt).toBeUndefined() + expect(json.data.updatedAt).toBeUndefined() + expect(json.data.principalId).toBeUndefined() + expect(json.data.contentJson).toBeUndefined() + }) + + it('returns 404 when entry is not found (CHANGELOG_NOT_FOUND)', async () => { + mockGetChangelogById.mockRejectedValue({ + code: 'CHANGELOG_NOT_FOUND', + message: 'Changelog entry not found', + }) + const res = await EntryGET({ + request: makeRequest(), + params: { entryId: 'changelog_missing' }, + }) + expect(res.status).toBe(404) + }) + + it('returns 404 when entry status is draft (not published)', async () => { + mockGetChangelogById.mockResolvedValue({ + ...MOCK_ENTRIES[0], + publishedAt: null, + status: 'draft' as const, + }) + const res = await EntryGET({ + request: makeRequest(), + params: { entryId: ENTRY_ID_1 }, + }) + expect(res.status).toBe(404) + }) + + it('returns 404 when entry status is scheduled (not yet published)', async () => { + mockGetChangelogById.mockResolvedValue({ + ...MOCK_ENTRIES[0], + publishedAt: new Date(Date.now() + 86400000), + status: 'scheduled' as const, + }) + const res = await EntryGET({ + request: makeRequest(), + params: { entryId: ENTRY_ID_1 }, + }) + expect(res.status).toBe(404) + }) + + it('serializes null publishedAt as null for published entry edge case', async () => { + mockGetChangelogById.mockResolvedValue({ + ...MOCK_ENTRIES[0], + publishedAt: null, + status: 'published' as const, + }) + // status=published but publishedAt=null edge — route returns the entry anyway + const res = await EntryGET({ + request: makeRequest(), + params: { entryId: ENTRY_ID_1 }, + }) + expect(res.status).toBe(200) + const json = await res.json() + expect(json.data.publishedAt).toBeNull() + }) +}) diff --git a/apps/web/src/routes/api/public/v1/changelog/index.ts b/apps/web/src/routes/api/public/v1/changelog/index.ts new file mode 100644 index 000000000..6d20dc655 --- /dev/null +++ b/apps/web/src/routes/api/public/v1/changelog/index.ts @@ -0,0 +1,40 @@ +import { createFileRoute } from '@tanstack/react-router' +import { successResponse, handleDomainError } from '@/lib/server/domains/api/responses' +import { listChangelogs } from '@/lib/server/domains/changelog/changelog.query' + +export const Route = createFileRoute('/api/public/v1/changelog/')({ + server: { + handlers: { + /** + * GET /api/public/v1/changelog + * Anonymous read — returns published changelog entries only. + */ + GET: async ({ request }) => { + try { + const url = new URL(request.url) + const cursor = url.searchParams.get('cursor') ?? undefined + const rawLimit = parseInt(url.searchParams.get('limit') ?? '20', 10) + const limit = Math.min(100, Math.max(1, isNaN(rawLimit) ? 20 : rawLimit)) + + const result = await listChangelogs({ status: 'published', cursor, limit }) + + return successResponse( + result.items.map((entry) => ({ + id: entry.id, + title: entry.title, + publishedAt: entry.publishedAt?.toISOString() ?? null, + })), + { + pagination: { + cursor: result.nextCursor, + hasMore: result.hasMore, + }, + } + ) + } catch (error) { + return handleDomainError(error) + } + }, + }, + }, +}) diff --git a/apps/web/src/routes/api/public/v1/config.ts b/apps/web/src/routes/api/public/v1/config.ts new file mode 100644 index 000000000..8be83517b --- /dev/null +++ b/apps/web/src/routes/api/public/v1/config.ts @@ -0,0 +1,19 @@ +import { createFileRoute } from '@tanstack/react-router' +import { successResponse, handleDomainError } from '@/lib/server/domains/api/responses' + +export const Route = createFileRoute('/api/public/v1/config')({ + server: { + handlers: { + GET: async () => { + try { + const { getPublicWidgetConfig } = + await import('@/lib/server/domains/settings/settings.widget') + const config = await getPublicWidgetConfig() + return successResponse(config) + } catch (error) { + return handleDomainError(error) + } + }, + }, + }, +}) diff --git a/apps/web/src/routes/api/public/v1/help/__tests__/help.test.ts b/apps/web/src/routes/api/public/v1/help/__tests__/help.test.ts new file mode 100644 index 000000000..fa52d204f --- /dev/null +++ b/apps/web/src/routes/api/public/v1/help/__tests__/help.test.ts @@ -0,0 +1,310 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockListPublicCategories = vi.fn() +const mockGetPublicArticleBySlug = vi.fn() +const mockHybridSearch = vi.fn() + +vi.mock('@tanstack/react-router', () => ({ + createFileRoute: vi.fn(() => (opts: unknown) => ({ options: opts })), +})) +vi.mock('@/lib/server/domains/help-center/help-center.service', () => ({ + listPublicCategories: (...args: unknown[]) => mockListPublicCategories(...args), + getPublicArticleBySlug: (...args: unknown[]) => mockGetPublicArticleBySlug(...args), +})) +vi.mock('@/lib/server/domains/help-center/help-center-search.service', () => ({ + hybridSearch: (...args: unknown[]) => mockHybridSearch(...args), +})) + +import { Route as CategoriesRoute } from '../categories/index' +import { Route as ArticleRoute } from '../articles/$slug' +import { Route as SearchRoute } from '../search' + +type CategoriesOpts = { + server: { handlers: { GET: (ctx: { request: Request }) => Promise } } +} +type ArticleOpts = { + server: { + handlers: { + GET: (ctx: { request: Request; params: { slug: string } }) => Promise + } + } +} +type SearchOpts = { + server: { handlers: { GET: (ctx: { request: Request }) => Promise } } +} + +const CategoriesGET = (CategoriesRoute as unknown as { options: CategoriesOpts }).options.server + .handlers.GET +const ArticleGET = (ArticleRoute as unknown as { options: ArticleOpts }).options.server.handlers.GET +const SearchGET = (SearchRoute as unknown as { options: SearchOpts }).options.server.handlers.GET + +// ─── Mock data ─────────────────────────────────────────────────────────────── + +const MOCK_CATEGORIES = [ + { + id: 'hcc_01', + name: 'Getting Started', + slug: 'getting-started', + description: 'Start here', + icon: null, + parentId: null, + isPublic: true, + position: 0, + articleCount: 3, + publishedArticleCount: 2, + recursiveArticleCount: 3, + recursivePublishedArticleCount: 2, + createdAt: new Date('2024-01-01T00:00:00.000Z'), + updatedAt: new Date('2024-01-02T00:00:00.000Z'), + }, + { + id: 'hcc_02', + name: 'Advanced', + slug: 'advanced', + description: null, + icon: null, + parentId: null, + isPublic: true, + position: 1, + articleCount: 1, + publishedArticleCount: 1, + recursiveArticleCount: 1, + recursivePublishedArticleCount: 1, + createdAt: new Date('2024-01-01T00:00:00.000Z'), + updatedAt: new Date('2024-01-02T00:00:00.000Z'), + }, +] + +const MOCK_ARTICLE = { + id: 'hca_01', + slug: 'how-to-start', + title: 'How to Start', + content: 'This is the content', + description: 'A short description', + categoryId: 'hcc_01', + position: null, + contentJson: null, + principalId: 'principal_01', + publishedAt: new Date('2024-03-01T00:00:00.000Z'), + viewCount: 5, + helpfulCount: 3, + notHelpfulCount: 1, + createdAt: new Date('2024-01-01T00:00:00.000Z'), + updatedAt: new Date('2024-01-02T00:00:00.000Z'), + category: { id: 'hcc_01', slug: 'getting-started', name: 'Getting Started' }, + author: null, +} + +const MOCK_SEARCH_RESULTS = [ + { + id: 'hca_01', + slug: 'how-to-start', + title: 'How to Start', + content: 'This is the content of the article', + categoryId: 'hcc_01', + categorySlug: 'getting-started', + categoryName: 'Getting Started', + }, + { + id: 'hca_02', + slug: 'advanced-config', + title: 'Advanced Config', + content: 'More content here', + categoryId: 'hcc_02', + categorySlug: 'advanced', + categoryName: 'Advanced', + }, +] + +function makeRequest(url: string): Request { + return new Request(url) +} + +// ============================================================= +// GET /api/public/v1/help/categories +// ============================================================= +describe('GET /api/public/v1/help/categories', () => { + beforeEach(() => { + mockListPublicCategories.mockReset() + mockListPublicCategories.mockResolvedValue(MOCK_CATEGORIES) + }) + + it('returns 200 with mapped public subset { id, name, slug, description }', async () => { + const res = await CategoriesGET({ + request: makeRequest('http://test/api/public/v1/help/categories'), + }) + expect(res.status).toBe(200) + const json = await res.json() + expect(json.data).toHaveLength(2) + const item = json.data[0] + expect(item.id).toBe('hcc_01') + expect(item.name).toBe('Getting Started') + expect(item.slug).toBe('getting-started') + expect(item.description).toBe('Start here') + }) + + it('does not expose internal fields', async () => { + const res = await CategoriesGET({ + request: makeRequest('http://test/api/public/v1/help/categories'), + }) + const json = await res.json() + const item = json.data[0] + expect(item.isPublic).toBeUndefined() + expect(item.position).toBeUndefined() + expect(item.parentId).toBeUndefined() + expect(item.icon).toBeUndefined() + expect(item.articleCount).toBeUndefined() + expect(item.createdAt).toBeUndefined() + expect(item.updatedAt).toBeUndefined() + }) + + it('returns null description when category has no description', async () => { + const res = await CategoriesGET({ + request: makeRequest('http://test/api/public/v1/help/categories'), + }) + const json = await res.json() + expect(json.data[1].description).toBeNull() + }) + + it('delegates errors to handleDomainError', async () => { + mockListPublicCategories.mockRejectedValue({ code: 'NOT_FOUND', message: 'not found' }) + const res = await CategoriesGET({ + request: makeRequest('http://test/api/public/v1/help/categories'), + }) + expect(res.status).toBe(404) + }) +}) + +// ============================================================= +// GET /api/public/v1/help/articles/:slug +// ============================================================= +describe('GET /api/public/v1/help/articles/:slug', () => { + beforeEach(() => { + mockGetPublicArticleBySlug.mockReset() + mockGetPublicArticleBySlug.mockResolvedValue(MOCK_ARTICLE) + }) + + it('returns 200 with { id, slug, title, content, categoryId } for a published article', async () => { + const res = await ArticleGET({ + request: makeRequest('http://test/api/public/v1/help/articles/how-to-start'), + params: { slug: 'how-to-start' }, + }) + expect(res.status).toBe(200) + const json = await res.json() + expect(json.data.id).toBe('hca_01') + expect(json.data.slug).toBe('how-to-start') + expect(json.data.title).toBe('How to Start') + expect(json.data.content).toBe('This is the content') + expect(json.data.categoryId).toBe('hcc_01') + }) + + it('does not expose internal fields', async () => { + const res = await ArticleGET({ + request: makeRequest('http://test/api/public/v1/help/articles/how-to-start'), + params: { slug: 'how-to-start' }, + }) + const json = await res.json() + expect(json.data.contentJson).toBeUndefined() + expect(json.data.principalId).toBeUndefined() + expect(json.data.viewCount).toBeUndefined() + expect(json.data.helpfulCount).toBeUndefined() + expect(json.data.createdAt).toBeUndefined() + expect(json.data.updatedAt).toBeUndefined() + }) + + it('returns 404 when article is not found (ARTICLE_NOT_FOUND)', async () => { + mockGetPublicArticleBySlug.mockRejectedValue({ + code: 'ARTICLE_NOT_FOUND', + message: 'Article not found', + }) + const res = await ArticleGET({ + request: makeRequest('http://test/api/public/v1/help/articles/missing'), + params: { slug: 'missing' }, + }) + expect(res.status).toBe(404) + }) + + it('returns 404 when article is unpublished (publishedAt is null)', async () => { + mockGetPublicArticleBySlug.mockRejectedValue({ + code: 'ARTICLE_NOT_FOUND', + message: 'Article not found', + }) + const res = await ArticleGET({ + request: makeRequest('http://test/api/public/v1/help/articles/draft-article'), + params: { slug: 'draft-article' }, + }) + expect(res.status).toBe(404) + }) + + it('passes the slug param to getPublicArticleBySlug', async () => { + await ArticleGET({ + request: makeRequest('http://test/api/public/v1/help/articles/how-to-start'), + params: { slug: 'how-to-start' }, + }) + expect(mockGetPublicArticleBySlug).toHaveBeenCalledWith('how-to-start') + }) +}) + +// ============================================================= +// GET /api/public/v1/help/search +// ============================================================= +describe('GET /api/public/v1/help/search', () => { + beforeEach(() => { + mockHybridSearch.mockReset() + mockHybridSearch.mockResolvedValue(MOCK_SEARCH_RESULTS) + }) + + it('returns empty array without calling hybridSearch when q is absent', async () => { + const res = await SearchGET({ + request: makeRequest('http://test/api/public/v1/help/search'), + }) + expect(res.status).toBe(200) + const json = await res.json() + expect(json.data).toEqual([]) + expect(mockHybridSearch).not.toHaveBeenCalled() + }) + + it('returns empty array without calling hybridSearch when q is blank whitespace', async () => { + const res = await SearchGET({ + request: makeRequest('http://test/api/public/v1/help/search?q= '), + }) + expect(res.status).toBe(200) + const json = await res.json() + expect(json.data).toEqual([]) + expect(mockHybridSearch).not.toHaveBeenCalled() + }) + + it('calls hybridSearch and maps results to { id, slug, title } when q has a value', async () => { + const res = await SearchGET({ + request: makeRequest('http://test/api/public/v1/help/search?q=start'), + }) + expect(res.status).toBe(200) + expect(mockHybridSearch).toHaveBeenCalledWith('start', expect.any(Number)) + const json = await res.json() + expect(json.data).toHaveLength(2) + const item = json.data[0] + expect(item.id).toBe('hca_01') + expect(item.slug).toBe('how-to-start') + expect(item.title).toBe('How to Start') + }) + + it('does not expose content or category fields in search results', async () => { + const res = await SearchGET({ + request: makeRequest('http://test/api/public/v1/help/search?q=start'), + }) + const json = await res.json() + const item = json.data[0] + expect(item.content).toBeUndefined() + expect(item.categoryId).toBeUndefined() + expect(item.categorySlug).toBeUndefined() + expect(item.categoryName).toBeUndefined() + }) + + it('delegates errors to handleDomainError when hybridSearch throws', async () => { + mockHybridSearch.mockRejectedValue(new Error('Search failed')) + const res = await SearchGET({ + request: makeRequest('http://test/api/public/v1/help/search?q=crash'), + }) + expect(res.status).toBe(500) + }) +}) diff --git a/apps/web/src/routes/api/public/v1/help/articles/$slug.ts b/apps/web/src/routes/api/public/v1/help/articles/$slug.ts new file mode 100644 index 000000000..822bfdbc1 --- /dev/null +++ b/apps/web/src/routes/api/public/v1/help/articles/$slug.ts @@ -0,0 +1,24 @@ +import { createFileRoute } from '@tanstack/react-router' +import { successResponse, handleDomainError } from '@/lib/server/domains/api/responses' +import { getPublicArticleBySlug } from '@/lib/server/domains/help-center/help-center.service' + +export const Route = createFileRoute('/api/public/v1/help/articles/$slug')({ + server: { + handlers: { + GET: async ({ request: _request, params }) => { + try { + const article = await getPublicArticleBySlug(params.slug) + return successResponse({ + id: article.id, + slug: article.slug, + title: article.title, + content: article.content, + categoryId: article.categoryId, + }) + } catch (error) { + return handleDomainError(error) + } + }, + }, + }, +}) diff --git a/apps/web/src/routes/api/public/v1/help/categories/index.ts b/apps/web/src/routes/api/public/v1/help/categories/index.ts new file mode 100644 index 000000000..1a0cbb91e --- /dev/null +++ b/apps/web/src/routes/api/public/v1/help/categories/index.ts @@ -0,0 +1,25 @@ +import { createFileRoute } from '@tanstack/react-router' +import { successResponse, handleDomainError } from '@/lib/server/domains/api/responses' +import { listPublicCategories } from '@/lib/server/domains/help-center/help-center.service' + +export const Route = createFileRoute('/api/public/v1/help/categories/')({ + server: { + handlers: { + GET: async ({ request: _request }) => { + try { + const categories = await listPublicCategories() + return successResponse( + categories.map((cat) => ({ + id: cat.id, + name: cat.name, + slug: cat.slug, + description: cat.description, + })) + ) + } catch (error) { + return handleDomainError(error) + } + }, + }, + }, +}) diff --git a/apps/web/src/routes/api/public/v1/help/search.ts b/apps/web/src/routes/api/public/v1/help/search.ts new file mode 100644 index 000000000..f08a97eff --- /dev/null +++ b/apps/web/src/routes/api/public/v1/help/search.ts @@ -0,0 +1,33 @@ +import { createFileRoute } from '@tanstack/react-router' +import { successResponse, handleDomainError } from '@/lib/server/domains/api/responses' +import { hybridSearch } from '@/lib/server/domains/help-center/help-center-search.service' + +export const Route = createFileRoute('/api/public/v1/help/search')({ + server: { + handlers: { + GET: async ({ request }) => { + try { + const url = new URL(request.url) + const q = url.searchParams.get('q')?.trim() + + if (!q) { + return successResponse([]) + } + + const limit = Math.min(Number(url.searchParams.get('limit')) || 10, 20) + const results = await hybridSearch(q, limit) + + return successResponse( + results.map((a) => ({ + id: a.id, + slug: a.slug, + title: a.title, + })) + ) + } catch (error) { + return handleDomainError(error) + } + }, + }, + }, +}) diff --git a/apps/web/src/routes/api/public/v1/openapi.json.ts b/apps/web/src/routes/api/public/v1/openapi.json.ts new file mode 100644 index 000000000..fbf33257e --- /dev/null +++ b/apps/web/src/routes/api/public/v1/openapi.json.ts @@ -0,0 +1,29 @@ +import { createFileRoute } from '@tanstack/react-router' +import { config } from '@/lib/server/config' + +export const Route = createFileRoute('/api/public/v1/openapi/json')({ + server: { + handlers: { + /** + * GET /api/public/v1/openapi.json + * Returns the OpenAPI 3.1 specification for the public end-user API. + * + * Public endpoint – no authentication required. + */ + GET: async () => { + const { buildPublicOpenApiDocument } = + await import('@/lib/server/domains/api/public-openapi') + + const spec = buildPublicOpenApiDocument(config.baseUrl) + + return Response.json(spec, { + headers: { + 'Content-Type': 'application/json', + 'Access-Control-Allow-Origin': '*', + 'Cache-Control': 'public, max-age=3600', + }, + }) + }, + }, + }, +}) diff --git a/apps/web/src/routes/api/public/v1/posts/$postId.comments.ts b/apps/web/src/routes/api/public/v1/posts/$postId.comments.ts new file mode 100644 index 000000000..b6bf601e9 --- /dev/null +++ b/apps/web/src/routes/api/public/v1/posts/$postId.comments.ts @@ -0,0 +1,109 @@ +import { createFileRoute } from '@tanstack/react-router' +import { z } from 'zod' +import { + successResponse, + createdResponse, + badRequestResponse, + handleDomainError, +} from '@/lib/server/domains/api/responses' +import { parseTypeId, parseOptionalTypeId } from '@/lib/server/domains/api/validation' +import { NotFoundError } from '@/lib/shared/errors' +import type { CommentId, PostId } from '@opencoven-feedback/ids' + +const createCommentSchema = z.object({ + content: z.string().min(1, 'Content is required').max(10000), + parentId: z.string().optional(), +}) + +export const Route = createFileRoute('/api/public/v1/posts/$postId/comments')({ + server: { + handlers: { + /** + * GET /api/public/v1/posts/:postId/comments + * Anonymous-read threaded comments for a post. + */ + GET: async ({ request: _request, params }) => { + try { + const postId = parseTypeId(params.postId, 'post', 'post ID') + + const { getPostWithDetails, getCommentsWithReplies } = + await import('@/lib/server/domains/posts/post.query') + + // C1/C2: enforce post visibility before returning any comments + const post = await getPostWithDetails(postId) + if (!post.board?.isPublic || post.deletedAt != null || post.canonicalPostId != null) { + throw new NotFoundError('POST_NOT_FOUND', 'Post not found') + } + + // C2: pass publicOnly so private/team comments are excluded and deleted leaves pruned + const comments = await getCommentsWithReplies(postId, undefined, { publicOnly: true }) + + type Comment = (typeof comments)[0] + + const serializeComment = (c: Comment): unknown => ({ + id: c.id, + content: c.content, + authorName: c.authorName, + createdAt: c.createdAt.toISOString(), + replies: c.replies.map(serializeComment), + }) + + return successResponse(comments.map(serializeComment)) + } catch (error) { + return handleDomainError(error) + } + }, + + /** + * POST /api/public/v1/posts/:postId/comments + * Authenticated end-user comment creation. + * requirePortalSession runs first — anonymous requests are rejected + * with 401 before any body parsing or DB work. + */ + POST: async ({ request, params }) => { + try { + const { requirePortalSession } = await import('@/lib/server/domains/api/portal-auth') + const session = await requirePortalSession(request) + + const postId = parseTypeId(params.postId, 'post', 'post ID') + + const body = await request.json() + const parsed = createCommentSchema.safeParse(body) + if (!parsed.success) { + return badRequestResponse('Invalid request body', { + errors: parsed.error.flatten().fieldErrors, + }) + } + + const parentId = parseOptionalTypeId( + parsed.data.parentId, + 'comment', + 'parent ID' + ) + + const { createComment } = await import('@/lib/server/domains/comments/comment.service') + + const result = await createComment( + { + postId, + content: parsed.data.content, + parentId, + }, + { + principalId: session.principal.id, + role: session.principal.role as 'admin' | 'member' | 'user', + } + ) + + return createdResponse({ + id: result.comment.id, + content: result.comment.content, + createdAt: result.comment.createdAt.toISOString(), + }) + } catch (error) { + return handleDomainError(error) + } + }, + }, + }, +}) diff --git a/apps/web/src/routes/api/public/v1/posts/$postId.ts b/apps/web/src/routes/api/public/v1/posts/$postId.ts new file mode 100644 index 000000000..4e2423fe8 --- /dev/null +++ b/apps/web/src/routes/api/public/v1/posts/$postId.ts @@ -0,0 +1,55 @@ +import { createFileRoute } from '@tanstack/react-router' +import { successResponse, handleDomainError } from '@/lib/server/domains/api/responses' +import { parseTypeId } from '@/lib/server/domains/api/validation' +import { NotFoundError } from '@/lib/shared/errors' +import type { PostId } from '@opencoven-feedback/ids' + +export const Route = createFileRoute('/api/public/v1/posts/$postId')({ + server: { + handlers: { + /** + * GET /api/public/v1/posts/:postId + * Anonymous-read single post — public-safe subset only. + */ + GET: async ({ request, params }) => { + try { + const postId = parseTypeId(params.postId, 'post', 'post ID') + + const { getPostWithDetails } = await import('@/lib/server/domains/posts/post.query') + const { optionalPortalSession } = await import('@/lib/server/domains/api/portal-auth') + + const [post, session] = await Promise.all([ + getPostWithDetails(postId), + optionalPortalSession(request), + ]) + + // C1/C3: enforce public visibility — return identical 404 for private/deleted/merged + if (!post.board?.isPublic || post.deletedAt != null || post.canonicalPostId != null) { + throw new NotFoundError('POST_NOT_FOUND', 'Post not found') + } + + let hasVoted = false + if (session) { + const { getAllUserVotedPostIds } = + await import('@/lib/server/domains/posts/post.public') + const voted = await getAllUserVotedPostIds(session.principal.id) + hasVoted = voted.has(postId) + } + + return successResponse({ + id: post.id, + title: post.title, + content: post.content, + voteCount: post.voteCount, + statusId: post.statusId, + boardId: post.boardId, + createdAt: post.createdAt.toISOString(), + hasVoted, + }) + } catch (error) { + return handleDomainError(error) + } + }, + }, + }, +}) diff --git a/apps/web/src/routes/api/public/v1/posts/$postId.vote.ts b/apps/web/src/routes/api/public/v1/posts/$postId.vote.ts new file mode 100644 index 000000000..26853242a --- /dev/null +++ b/apps/web/src/routes/api/public/v1/posts/$postId.vote.ts @@ -0,0 +1,30 @@ +import { createFileRoute } from '@tanstack/react-router' +import { successResponse, handleDomainError } from '@/lib/server/domains/api/responses' +import { parseTypeId } from '@/lib/server/domains/api/validation' +import type { PostId } from '@opencoven-feedback/ids' + +export const Route = createFileRoute('/api/public/v1/posts/$postId/vote')({ + server: { + handlers: { + /** + * POST /api/public/v1/posts/:postId/vote + * Toggle vote on a post — auth required, vote attributed to session principal. + */ + POST: async ({ request, params }) => { + try { + const { requirePortalSession } = await import('@/lib/server/domains/api/portal-auth') + const session = await requirePortalSession(request) + + const postId = parseTypeId(params.postId, 'post', 'post ID') + + const { voteOnPost } = await import('@/lib/server/domains/posts/post.voting') + const result = await voteOnPost(postId, session.principal.id) + + return successResponse({ voted: result.voted, voteCount: result.voteCount }) + } catch (error) { + return handleDomainError(error) + } + }, + }, + }, +}) diff --git a/apps/web/src/routes/api/public/v1/posts/__tests__/comment-create.test.ts b/apps/web/src/routes/api/public/v1/posts/__tests__/comment-create.test.ts new file mode 100644 index 000000000..3ed4c50d3 --- /dev/null +++ b/apps/web/src/routes/api/public/v1/posts/__tests__/comment-create.test.ts @@ -0,0 +1,182 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { CommentId, PostId, PrincipalId } from '@opencoven-feedback/ids' +import { UnauthorizedError } from '@/lib/shared/errors' + +// ---- mocks ---------------------------------------------------------------- + +const mockRequirePortalSession = vi.fn() +const mockCreateComment = vi.fn() + +vi.mock('@tanstack/react-router', () => ({ + createFileRoute: vi.fn(() => (opts: unknown) => ({ options: opts })), +})) +vi.mock('@/lib/server/domains/api/portal-auth', () => ({ + requirePortalSession: (...args: unknown[]) => mockRequirePortalSession(...args), +})) +vi.mock('@/lib/server/domains/comments/comment.service', () => ({ + createComment: (...args: unknown[]) => mockCreateComment(...args), +})) +vi.mock('@/lib/server/domains/api/validation', () => ({ + parseTypeId: (value: string) => value as T, + parseOptionalTypeId: (value: string | null | undefined) => + value ? (value as T) : undefined, +})) + +// ---- import route --------------------------------------------------------- + +import { Route } from '../$postId.comments' + +type RouteOpts = { + server: { + handlers: { + GET: (ctx: { request: Request; params: { postId: string } }) => Promise + POST: (ctx: { request: Request; params: { postId: string } }) => Promise + } + } +} + +const POST = (Route as unknown as { options: RouteOpts }).options.server.handlers.POST + +// ---- fixtures ------------------------------------------------------------- + +const POST_ID = 'post_01kqhxq697fvgat0fn8rr1r7ea' as unknown as PostId +const COMMENT_ID = 'comment_01kqhxq697fvgat0fn8rr1r7eb' as unknown as CommentId +const PRINCIPAL_ID = 'principal_01kqhxq697fvgat0fn8rr1r7ew' as unknown as PrincipalId + +const MOCK_SESSION = { + user: { id: 'user_01', email: 'alice@example.com', name: 'Alice', image: null }, + principal: { id: PRINCIPAL_ID, role: 'user', type: 'user' }, +} + +const CREATED_COMMENT = { + comment: { + id: COMMENT_ID, + postId: POST_ID, + parentId: null, + content: 'This is a comment', + principalId: PRINCIPAL_ID, + isTeamMember: false, + isPrivate: false, + createdAt: new Date('2024-06-01T12:00:00.000Z'), + }, + post: { id: POST_ID, title: 'Some Post', boardSlug: 'feedback' }, +} + +function makePostRequest(body: unknown, token = 'valid-token'): Request { + return new Request(`http://test/api/public/v1/posts/${POST_ID}/comments`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + }) +} + +// ---- tests ---------------------------------------------------------------- + +describe('POST /api/public/v1/posts/:postId/comments', () => { + beforeEach(() => { + vi.clearAllMocks() + mockRequirePortalSession.mockResolvedValue(MOCK_SESSION) + mockCreateComment.mockResolvedValue(CREATED_COMMENT) + }) + + it('(a) anonymous request → 401, createComment NOT called', async () => { + mockRequirePortalSession.mockRejectedValue(new UnauthorizedError('Authentication required.')) + + const res = await POST({ + request: new Request(`http://test/api/public/v1/posts/${POST_ID}/comments`, { + method: 'POST', + }), + params: { postId: String(POST_ID) }, + }) + + expect(res.status).toBe(401) + expect(mockCreateComment).not.toHaveBeenCalled() + }) + + it('(b) signed-in + valid body → 201, createComment called with author = session.principal.id', async () => { + const res = await POST({ + request: makePostRequest({ content: 'This is a comment' }), + params: { postId: String(POST_ID) }, + }) + + expect(res.status).toBe(201) + const json = await res.json() + expect(json.data).toMatchObject({ + id: COMMENT_ID, + content: 'This is a comment', + }) + expect(typeof json.data.createdAt).toBe('string') + + expect(mockCreateComment).toHaveBeenCalledOnce() + const [inputArg, authorArg] = mockCreateComment.mock.calls[0] + expect(inputArg.content).toBe('This is a comment') + expect(authorArg.principalId).toBe(PRINCIPAL_ID) + }) + + it('(c) empty content → 400, createComment NOT called', async () => { + const res = await POST({ + request: makePostRequest({ content: '' }), + params: { postId: String(POST_ID) }, + }) + + expect(res.status).toBe(400) + expect(mockCreateComment).not.toHaveBeenCalled() + }) + + it('(c2) missing content → 400, createComment NOT called', async () => { + const res = await POST({ + request: makePostRequest({}), + params: { postId: String(POST_ID) }, + }) + + expect(res.status).toBe(400) + expect(mockCreateComment).not.toHaveBeenCalled() + }) + + it('(c3) content over 10000 chars → 400, createComment NOT called', async () => { + const res = await POST({ + request: makePostRequest({ content: 'x'.repeat(10001) }), + params: { postId: String(POST_ID) }, + }) + + expect(res.status).toBe(400) + expect(mockCreateComment).not.toHaveBeenCalled() + }) + + it('(d) author-injection: body includes authorPrincipalId/principalId → IGNORED, session principal used', async () => { + const ATTACKER_PRINCIPAL = 'principal_attacker' as unknown as PrincipalId + + const res = await POST({ + request: makePostRequest({ + content: 'Injected comment', + authorPrincipalId: ATTACKER_PRINCIPAL, + principalId: ATTACKER_PRINCIPAL, + author: ATTACKER_PRINCIPAL, + }), + params: { postId: String(POST_ID) }, + }) + + expect(res.status).toBe(201) + expect(mockCreateComment).toHaveBeenCalledOnce() + const [, authorArg] = mockCreateComment.mock.calls[0] + expect(authorArg.principalId).toBe(PRINCIPAL_ID) + expect(authorArg.principalId).not.toBe(ATTACKER_PRINCIPAL) + }) + + it('(e) parentId is passed through to createComment when provided', async () => { + const PARENT_ID = 'comment_parent_01' as unknown as CommentId + + const res = await POST({ + request: makePostRequest({ content: 'A reply', parentId: String(PARENT_ID) }), + params: { postId: String(POST_ID) }, + }) + + expect(res.status).toBe(201) + expect(mockCreateComment).toHaveBeenCalledOnce() + const [inputArg] = mockCreateComment.mock.calls[0] + expect(inputArg.parentId).toBeDefined() + }) +}) diff --git a/apps/web/src/routes/api/public/v1/posts/__tests__/detail.test.ts b/apps/web/src/routes/api/public/v1/posts/__tests__/detail.test.ts new file mode 100644 index 000000000..41e63d029 --- /dev/null +++ b/apps/web/src/routes/api/public/v1/posts/__tests__/detail.test.ts @@ -0,0 +1,352 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { CommentId, PostId, PrincipalId, StatusId, BoardId } from '@opencoven-feedback/ids' + +// --- mock fns --- +const mockGetPostWithDetails = vi.fn() +const mockGetCommentsWithReplies = vi.fn() +const mockOptionalPortalSession = vi.fn() +const mockGetAllUserVotedPostIds = vi.fn() +const mockParseTypeId = vi.fn() + +vi.mock('@tanstack/react-router', () => ({ + createFileRoute: vi.fn(() => (opts: unknown) => ({ options: opts })), +})) +vi.mock('@/lib/server/domains/posts/post.query', () => ({ + getPostWithDetails: (...args: unknown[]) => mockGetPostWithDetails(...args), + getCommentsWithReplies: (...args: unknown[]) => mockGetCommentsWithReplies(...args), +})) +vi.mock('@/lib/server/domains/api/portal-auth', () => ({ + optionalPortalSession: (...args: unknown[]) => mockOptionalPortalSession(...args), +})) +vi.mock('@/lib/server/domains/posts/post.public', () => ({ + getAllUserVotedPostIds: (...args: unknown[]) => mockGetAllUserVotedPostIds(...args), +})) +vi.mock('@/lib/server/domains/api/validation', () => ({ + parseTypeId: (value: string, prefix: string, paramName?: string) => + mockParseTypeId(value, prefix, paramName) as T, +})) + +import { Route as DetailRoute } from '../$postId' +import { Route as CommentsRoute } from '../$postId.comments' + +type DetailRouteOpts = { + server: { + handlers: { + GET: (ctx: { request: Request; params: { postId: string } }) => Promise + } + } +} +type CommentsRouteOpts = { + server: { + handlers: { + GET: (ctx: { request: Request; params: { postId: string } }) => Promise + } + } +} + +const DetailGET = (DetailRoute as unknown as { options: DetailRouteOpts }).options.server.handlers + .GET +const CommentsGET = (CommentsRoute as unknown as { options: CommentsRouteOpts }).options.server + .handlers.GET + +// --- fixtures --- +const POST_ID = 'post_01kqhxq697fvgat0fn8rr1r7ea' as unknown as PostId +const BOARD_ID = 'board_01kqhxq697fvgat0geegv834v0' as unknown as BoardId +const STATUS_ID = 'status_01kqhxq697fvgat0geegv834v1' as unknown as StatusId +const PRINCIPAL_ID = 'principal_01kqhxq697fvgat0fn8rr1r7ew' as unknown as PrincipalId +const COMMENT_ID_1 = 'comment_01kqhxq697fvgat0fn8rr1r7eb' as unknown as CommentId +const COMMENT_ID_2 = 'comment_01kqhxq697fvgat0fn8rr1r7ec' as unknown as CommentId + +const MOCK_POST = { + id: POST_ID, + title: 'Test Post', + content: 'Post content', + contentJson: null, + voteCount: 7, + commentCount: 2, + boardId: BOARD_ID, + boardSlug: 'general', + boardName: 'General', + board: { id: BOARD_ID, name: 'General', slug: 'general', isPublic: true }, + statusId: STATUS_ID, + authorName: 'Alice', + authorEmail: 'alice@example.com', + ownerPrincipalId: null, + tags: [], + roadmapIds: [], + pinnedComment: null, + summaryJson: null, + summaryUpdatedAt: null, + canonicalPostId: null, + mergedAt: null, + isCommentsLocked: false, + createdAt: new Date('2024-01-01T00:00:00.000Z'), + updatedAt: new Date('2024-01-02T00:00:00.000Z'), + deletedAt: null, +} + +const MOCK_COMMENTS = [ + { + id: COMMENT_ID_1, + postId: POST_ID, + parentId: null, + content: 'Top-level comment', + authorName: 'Bob', + principalId: PRINCIPAL_ID, + isTeamMember: false, + isPrivate: false, + createdAt: new Date('2024-01-03T00:00:00.000Z'), + deletedAt: null, + deletedByPrincipalId: null, + reactions: [], + replies: [ + { + id: COMMENT_ID_2, + postId: POST_ID, + parentId: COMMENT_ID_1, + content: 'Nested reply', + authorName: 'Carol', + principalId: PRINCIPAL_ID, + isTeamMember: false, + isPrivate: false, + createdAt: new Date('2024-01-04T00:00:00.000Z'), + deletedAt: null, + deletedByPrincipalId: null, + reactions: [], + replies: [], + }, + ], + }, +] + +function makeRequest(url = 'http://test/api/public/v1/posts/post_01', token?: string): Request { + const headers: Record = {} + if (token) headers['authorization'] = `Bearer ${token}` + return new Request(url, { headers }) +} + +// ============================================================ +// GET /api/public/v1/posts/:postId +// ============================================================ +describe('GET /api/public/v1/posts/:postId', () => { + beforeEach(() => { + mockGetPostWithDetails.mockReset() + mockOptionalPortalSession.mockReset() + mockGetAllUserVotedPostIds.mockReset() + mockParseTypeId.mockImplementation((value: string) => value) + + mockGetPostWithDetails.mockResolvedValue(MOCK_POST) + mockOptionalPortalSession.mockResolvedValue(null) + }) + + it('returns 200 with public-safe post fields', async () => { + const res = await DetailGET({ request: makeRequest(), params: { postId: POST_ID } }) + expect(res.status).toBe(200) + const json = await res.json() + const data = json.data + expect(data.id).toBe(POST_ID) + expect(data.title).toBe('Test Post') + expect(data.content).toBe('Post content') + expect(data.voteCount).toBe(7) + expect(data.statusId).toBe(STATUS_ID) + expect(data.boardId).toBe(BOARD_ID) + expect(data.createdAt).toBe('2024-01-01T00:00:00.000Z') + }) + + it('does not expose private admin fields', async () => { + const res = await DetailGET({ request: makeRequest(), params: { postId: POST_ID } }) + const json = await res.json() + const data = json.data + expect(data.authorEmail).toBeUndefined() + expect(data.ownerPrincipalId).toBeUndefined() + expect(data.contentJson).toBeUndefined() + expect(data.summaryJson).toBeUndefined() + expect(data.updatedAt).toBeUndefined() + expect(data.deletedAt).toBeUndefined() + }) + + it('returns hasVoted: false when anonymous', async () => { + mockOptionalPortalSession.mockResolvedValue(null) + const res = await DetailGET({ request: makeRequest(), params: { postId: POST_ID } }) + const json = await res.json() + expect(json.data.hasVoted).toBe(false) + expect(mockGetAllUserVotedPostIds).not.toHaveBeenCalled() + }) + + it('returns hasVoted: true when authed and post is in voted set', async () => { + mockOptionalPortalSession.mockResolvedValue({ + user: { id: 'user_01', email: 'a@b.com', name: 'A', image: null }, + principal: { id: PRINCIPAL_ID, role: 'user', type: 'user' }, + }) + mockGetAllUserVotedPostIds.mockResolvedValue(new Set([POST_ID])) + + const res = await DetailGET({ + request: makeRequest(undefined, 'mytoken'), + params: { postId: POST_ID }, + }) + const json = await res.json() + expect(json.data.hasVoted).toBe(true) + expect(mockGetAllUserVotedPostIds).toHaveBeenCalledWith(PRINCIPAL_ID) + }) + + it('returns hasVoted: false when authed but post not in voted set', async () => { + mockOptionalPortalSession.mockResolvedValue({ + user: { id: 'user_01', email: 'a@b.com', name: 'A', image: null }, + principal: { id: PRINCIPAL_ID, role: 'user', type: 'user' }, + }) + mockGetAllUserVotedPostIds.mockResolvedValue(new Set()) + + const res = await DetailGET({ + request: makeRequest(undefined, 'mytoken'), + params: { postId: POST_ID }, + }) + const json = await res.json() + expect(json.data.hasVoted).toBe(false) + }) + + it('returns 404 when getPostWithDetails throws POST_NOT_FOUND', async () => { + mockGetPostWithDetails.mockRejectedValue({ code: 'POST_NOT_FOUND', message: 'Post not found' }) + const res = await DetailGET({ request: makeRequest(), params: { postId: POST_ID } }) + expect(res.status).toBe(404) + }) + + it('delegates other errors to handleDomainError', async () => { + mockGetPostWithDetails.mockRejectedValue({ code: 'VALIDATION_ERROR', message: 'bad id' }) + const res = await DetailGET({ request: makeRequest(), params: { postId: POST_ID } }) + expect(res.status).toBe(400) + }) + + it('calls parseTypeId with the postId param', async () => { + await DetailGET({ request: makeRequest(), params: { postId: POST_ID } }) + expect(mockParseTypeId).toHaveBeenCalledWith(POST_ID, 'post', 'post ID') + }) +}) + +// ============================================================ +// GET /api/public/v1/posts/:postId/comments +// ============================================================ +describe('GET /api/public/v1/posts/:postId/comments', () => { + beforeEach(() => { + mockGetPostWithDetails.mockReset() + mockGetCommentsWithReplies.mockReset() + mockParseTypeId.mockImplementation((value: string) => value) + + mockGetPostWithDetails.mockResolvedValue(MOCK_POST) + mockGetCommentsWithReplies.mockResolvedValue(MOCK_COMMENTS) + }) + + it('returns 200 with serialized comments array', async () => { + const res = await CommentsGET({ request: makeRequest(), params: { postId: POST_ID } }) + expect(res.status).toBe(200) + const json = await res.json() + expect(Array.isArray(json.data)).toBe(true) + expect(json.data).toHaveLength(1) + }) + + it('serializes top-level comment fields correctly', async () => { + const res = await CommentsGET({ request: makeRequest(), params: { postId: POST_ID } }) + const json = await res.json() + const comment = json.data[0] + expect(comment.id).toBe(COMMENT_ID_1) + expect(comment.content).toBe('Top-level comment') + expect(comment.authorName).toBe('Bob') + expect(comment.createdAt).toBe('2024-01-03T00:00:00.000Z') + expect(Array.isArray(comment.replies)).toBe(true) + }) + + it('serializes nested replies recursively', async () => { + const res = await CommentsGET({ request: makeRequest(), params: { postId: POST_ID } }) + const json = await res.json() + const reply = json.data[0].replies[0] + expect(reply.id).toBe(COMMENT_ID_2) + expect(reply.content).toBe('Nested reply') + expect(reply.authorName).toBe('Carol') + expect(reply.createdAt).toBe('2024-01-04T00:00:00.000Z') + expect(reply.replies).toEqual([]) + }) + + it('returns empty array when no comments', async () => { + mockGetCommentsWithReplies.mockResolvedValue([]) + const res = await CommentsGET({ request: makeRequest(), params: { postId: POST_ID } }) + const json = await res.json() + expect(json.data).toEqual([]) + }) + + it('delegates errors to handleDomainError', async () => { + mockGetCommentsWithReplies.mockRejectedValue({ code: 'POST_NOT_FOUND', message: 'not found' }) + const res = await CommentsGET({ request: makeRequest(), params: { postId: POST_ID } }) + expect(res.status).toBe(404) + }) + + it('calls parseTypeId with the postId param', async () => { + await CommentsGET({ request: makeRequest(), params: { postId: POST_ID } }) + expect(mockParseTypeId).toHaveBeenCalledWith(POST_ID, 'post', 'post ID') + }) + + // C1/C2 — private-board post returns 404 on comments route + it('returns 404 when post board is not public', async () => { + mockGetPostWithDetails.mockResolvedValue({ + ...MOCK_POST, + board: { ...MOCK_POST.board, isPublic: false }, + }) + const res = await CommentsGET({ request: makeRequest(), params: { postId: POST_ID } }) + expect(res.status).toBe(404) + const json = await res.json() + expect(json.error?.message ?? json.message).toBe('Post not found') + }) + + // C2 — getCommentsWithReplies is called with publicOnly: true + it('calls getCommentsWithReplies with { publicOnly: true }', async () => { + await CommentsGET({ request: makeRequest(), params: { postId: POST_ID } }) + expect(mockGetCommentsWithReplies).toHaveBeenCalledWith(POST_ID, undefined, { + publicOnly: true, + }) + }) +}) + +// ============================================================ +// Visibility guards — GET /api/public/v1/posts/:postId (C1/C3) +// ============================================================ +describe('GET /api/public/v1/posts/:postId — visibility guards', () => { + beforeEach(() => { + mockGetPostWithDetails.mockReset() + mockOptionalPortalSession.mockReset() + mockGetAllUserVotedPostIds.mockReset() + mockParseTypeId.mockImplementation((value: string) => value) + + mockOptionalPortalSession.mockResolvedValue(null) + }) + + it('returns 404 with "Post not found" when board is not public (C1)', async () => { + mockGetPostWithDetails.mockResolvedValue({ + ...MOCK_POST, + board: { ...MOCK_POST.board, isPublic: false }, + }) + const res = await DetailGET({ request: makeRequest(), params: { postId: POST_ID } }) + expect(res.status).toBe(404) + const json = await res.json() + expect(json.error?.message ?? json.message).toBe('Post not found') + }) + + it('returns 404 with "Post not found" when post is soft-deleted (C3)', async () => { + mockGetPostWithDetails.mockResolvedValue({ + ...MOCK_POST, + deletedAt: new Date('2024-06-01T00:00:00.000Z'), + }) + const res = await DetailGET({ request: makeRequest(), params: { postId: POST_ID } }) + expect(res.status).toBe(404) + const json = await res.json() + expect(json.error?.message ?? json.message).toBe('Post not found') + }) + + it('returns 404 with "Post not found" when post is merged (canonicalPostId set) (C3)', async () => { + mockGetPostWithDetails.mockResolvedValue({ + ...MOCK_POST, + canonicalPostId: 'post_other' as unknown as typeof POST_ID, + }) + const res = await DetailGET({ request: makeRequest(), params: { postId: POST_ID } }) + expect(res.status).toBe(404) + const json = await res.json() + expect(json.error?.message ?? json.message).toBe('Post not found') + }) +}) diff --git a/apps/web/src/routes/api/public/v1/posts/__tests__/index.test.ts b/apps/web/src/routes/api/public/v1/posts/__tests__/index.test.ts new file mode 100644 index 000000000..05a17636f --- /dev/null +++ b/apps/web/src/routes/api/public/v1/posts/__tests__/index.test.ts @@ -0,0 +1,255 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { BoardId, PostId, PrincipalId, StatusId } from '@opencoven-feedback/ids' + +const mockListPublicPostFeed = vi.fn() +const mockOptionalPortalSession = vi.fn() +const mockGetAllUserVotedPostIds = vi.fn() +const mockListBoardsWithDetails = vi.fn() + +vi.mock('@tanstack/react-router', () => ({ + createFileRoute: vi.fn(() => (opts: unknown) => ({ options: opts })), +})) +vi.mock('@/lib/server/domains/posts/post.public-list', () => ({ + listPublicPostFeed: (...args: unknown[]) => mockListPublicPostFeed(...args), +})) +vi.mock('@/lib/server/domains/api/portal-auth', () => ({ + optionalPortalSession: (...args: unknown[]) => mockOptionalPortalSession(...args), +})) +vi.mock('@/lib/server/domains/posts/post.public', () => ({ + getAllUserVotedPostIds: (...args: unknown[]) => mockGetAllUserVotedPostIds(...args), +})) +vi.mock('@/lib/server/domains/boards/board.service', () => ({ + listBoardsWithDetails: (...args: unknown[]) => mockListBoardsWithDetails(...args), +})) + +import { Route } from '../index' + +type RouteOpts = { + server: { handlers: { GET: (ctx: { request: Request }) => Promise } } +} +const GET = (Route as unknown as { options: RouteOpts }).options.server.handlers.GET + +const POST_ID_1 = 'post_01kqhxq697fvgat0fn8rr1r7ea' as unknown as PostId +const POST_ID_2 = 'post_01kqhxq697fvgat0fn8rr1r7eb' as unknown as PostId +const BOARD_ID = 'board_01kqhxq697fvgat0geegv834v0' as unknown as BoardId +const STATUS_ID = 'status_01kqhxq697fvgat0geegv834v1' as unknown as StatusId +const PRINCIPAL_ID = 'principal_01kqhxq697fvgat0fn8rr1r7ew' as unknown as PrincipalId + +const MOCK_POSTS = [ + { + id: POST_ID_1, + title: 'Post One', + voteCount: 5, + statusId: STATUS_ID, + boardId: BOARD_ID, + createdAt: '2024-01-01T00:00:00.000Z', + }, + { + id: POST_ID_2, + title: 'Post Two', + voteCount: 2, + statusId: null, + boardId: BOARD_ID, + createdAt: '2024-01-02T00:00:00.000Z', + }, +] + +function makeRequest(url = 'http://test/api/public/v1/posts'): Request { + return new Request(url) +} + +function makeAuthedRequest(url = 'http://test/api/public/v1/posts', token = 'test-token'): Request { + return new Request(url, { headers: { authorization: `Bearer ${token}` } }) +} + +describe('GET /api/public/v1/posts', () => { + beforeEach(() => { + mockListPublicPostFeed.mockReset() + mockOptionalPortalSession.mockReset() + mockGetAllUserVotedPostIds.mockReset() + + mockListPublicPostFeed.mockResolvedValue({ + items: MOCK_POSTS, + cursor: null, + hasMore: false, + }) + mockOptionalPortalSession.mockResolvedValue(null) + }) + + it('authenticated session: hasVoted true for voted post ids', async () => { + mockOptionalPortalSession.mockResolvedValue({ + user: { id: 'user_01', email: 'test@test.com', name: 'Test', image: null }, + principal: { id: PRINCIPAL_ID, role: 'user', type: 'user' }, + }) + mockGetAllUserVotedPostIds.mockResolvedValue(new Set([POST_ID_1])) + + const res = await GET({ request: makeAuthedRequest() }) + expect(res.status).toBe(200) + const json = await res.json() + expect(json.data).toHaveLength(2) + const post1 = json.data.find((p: { id: string }) => p.id === POST_ID_1) + const post2 = json.data.find((p: { id: string }) => p.id === POST_ID_2) + expect(post1.hasVoted).toBe(true) + expect(post2.hasVoted).toBe(false) + expect(mockGetAllUserVotedPostIds).toHaveBeenCalledWith(PRINCIPAL_ID) + }) + + it('anonymous (optionalPortalSession → null): all hasVoted false', async () => { + mockOptionalPortalSession.mockResolvedValue(null) + + const res = await GET({ request: makeRequest() }) + expect(res.status).toBe(200) + const json = await res.json() + expect(json.data).toHaveLength(2) + for (const post of json.data) { + expect(post.hasVoted).toBe(false) + } + expect(mockGetAllUserVotedPostIds).not.toHaveBeenCalled() + }) + + it('pagination meta passes through (hasMore, cursor)', async () => { + mockListPublicPostFeed.mockResolvedValue({ + items: MOCK_POSTS, + cursor: POST_ID_2, + hasMore: true, + }) + + const res = await GET({ request: makeRequest() }) + expect(res.status).toBe(200) + const json = await res.json() + expect(json.meta.pagination.hasMore).toBe(true) + expect(json.meta.pagination.cursor).toBe(POST_ID_2) + }) + + it('passes query params to listPublicPostFeed', async () => { + const url = + 'http://test/api/public/v1/posts?limit=5&sort=votes&boardId=board_01kqhxq697fvgat0geegv834v0&cursor=post_cursor' + const res = await GET({ request: makeRequest(url) }) + expect(res.status).toBe(200) + expect(mockListPublicPostFeed).toHaveBeenCalledWith({ + limit: 5, + sort: 'votes', + boardId: 'board_01kqhxq697fvgat0geegv834v0', + cursor: 'post_cursor', + }) + }) + + it('invalid boardId string results in boardId: undefined passed to listPublicPostFeed', async () => { + const url = 'http://test/api/public/v1/posts?boardId=not-a-valid-typeid' + await GET({ request: makeRequest(url) }) + expect(mockListPublicPostFeed).toHaveBeenCalledWith( + expect.objectContaining({ boardId: undefined }) + ) + }) + + it('clamps limit to 100 maximum', async () => { + const url = 'http://test/api/public/v1/posts?limit=999' + await GET({ request: makeRequest(url) }) + expect(mockListPublicPostFeed).toHaveBeenCalledWith(expect.objectContaining({ limit: 100 })) + }) + + it('clamps limit to 1 minimum', async () => { + const url = 'http://test/api/public/v1/posts?limit=0' + await GET({ request: makeRequest(url) }) + expect(mockListPublicPostFeed).toHaveBeenCalledWith(expect.objectContaining({ limit: 1 })) + }) + + it('defaults limit to 20 and sort to newest', async () => { + await GET({ request: makeRequest() }) + expect(mockListPublicPostFeed).toHaveBeenCalledWith( + expect.objectContaining({ limit: 20, sort: 'newest' }) + ) + }) + + it('delegates errors to handleDomainError', async () => { + mockListPublicPostFeed.mockRejectedValue({ code: 'NOT_FOUND', message: 'not found' }) + const res = await GET({ request: makeRequest() }) + expect(res.status).toBe(404) + }) +}) + +// ---- Boards route tests ---- + +import { Route as BoardsRoute } from '../../boards/index' + +type BoardsRouteOpts = { + server: { handlers: { GET: () => Promise } } +} +const BoardsGET = (BoardsRoute as unknown as { options: BoardsRouteOpts }).options.server.handlers + .GET + +describe('GET /api/public/v1/boards', () => { + beforeEach(() => { + mockListBoardsWithDetails.mockReset() + }) + + it('returns only public boards', async () => { + mockListBoardsWithDetails.mockResolvedValue([ + { + id: BOARD_ID, + name: 'Public Board', + slug: 'public-board', + description: 'A public board', + isPublic: true, + postCount: 3, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + settings: {}, + }, + { + id: 'board_private' as unknown as BoardId, + name: 'Private Board', + slug: 'private-board', + description: null, + isPublic: false, + postCount: 1, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + settings: {}, + }, + ]) + + const res = await BoardsGET() + expect(res.status).toBe(200) + const json = await res.json() + expect(json.data).toHaveLength(1) + expect(json.data[0].name).toBe('Public Board') + expect(json.data[0]).toMatchObject({ + id: BOARD_ID, + name: 'Public Board', + slug: 'public-board', + description: 'A public board', + postCount: 3, + }) + }) + + it('returns empty array when no public boards', async () => { + mockListBoardsWithDetails.mockResolvedValue([ + { + id: 'board_private' as unknown as BoardId, + name: 'Private', + slug: 'private', + description: null, + isPublic: false, + postCount: 0, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + settings: {}, + }, + ]) + + const res = await BoardsGET() + expect(res.status).toBe(200) + const json = await res.json() + expect(json.data).toHaveLength(0) + }) + + it('delegates errors to handleDomainError', async () => { + mockListBoardsWithDetails.mockRejectedValue({ code: 'NOT_FOUND', message: 'not found' }) + const res = await BoardsGET() + expect(res.status).toBe(404) + }) +}) diff --git a/apps/web/src/routes/api/public/v1/posts/__tests__/submit.test.ts b/apps/web/src/routes/api/public/v1/posts/__tests__/submit.test.ts new file mode 100644 index 000000000..7906d774e --- /dev/null +++ b/apps/web/src/routes/api/public/v1/posts/__tests__/submit.test.ts @@ -0,0 +1,232 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { BoardId, PostId, PrincipalId } from '@opencoven-feedback/ids' +import { UnauthorizedError } from '@/lib/shared/errors' + +// ---- mocks ---------------------------------------------------------------- + +const mockRequirePortalSession = vi.fn() +const mockCreatePost = vi.fn() +const mockGetBoardById = vi.fn() + +vi.mock('@tanstack/react-router', () => ({ + createFileRoute: vi.fn(() => (opts: unknown) => ({ options: opts })), +})) +vi.mock('@/lib/server/domains/api/portal-auth', () => ({ + optionalPortalSession: vi.fn().mockResolvedValue(null), + requirePortalSession: (...args: unknown[]) => mockRequirePortalSession(...args), +})) +vi.mock('@/lib/server/domains/posts/post.service', () => ({ + createPost: (...args: unknown[]) => mockCreatePost(...args), +})) +vi.mock('@/lib/server/domains/boards/board.service', () => ({ + getBoardById: (...args: unknown[]) => mockGetBoardById(...args), + // keep other exports harmless + listBoardsWithDetails: vi.fn().mockResolvedValue([]), +})) +// The route also dynamic-imports post.public-list; mock it to prevent DB hits +vi.mock('@/lib/server/domains/posts/post.public-list', () => ({ + listPublicPostFeed: vi.fn().mockResolvedValue({ items: [], cursor: null, hasMore: false }), +})) +vi.mock('@/lib/server/domains/posts/post.public', () => ({ + getAllUserVotedPostIds: vi.fn().mockResolvedValue(new Set()), +})) + +// ---- import route --------------------------------------------------------- + +import { Route } from '../index' + +type RouteOpts = { + server: { + handlers: { + GET: (ctx: { request: Request }) => Promise + POST: (ctx: { request: Request }) => Promise + } + } +} + +const POST = (Route as unknown as { options: RouteOpts }).options.server.handlers.POST + +// ---- fixtures ------------------------------------------------------------- + +const BOARD_ID = 'board_01kqhxq697fvgat0geegv834v0' as unknown as BoardId +const PRINCIPAL_ID = 'principal_01kqhxq697fvgat0fn8rr1r7ew' as unknown as PrincipalId +const POST_ID = 'post_01kqhxq697fvgat0fn8rr1r7ea' as unknown as PostId + +const MOCK_SESSION = { + user: { id: 'user_01', email: 'alice@example.com', name: 'Alice', image: null }, + principal: { id: PRINCIPAL_ID, role: 'user', type: 'user' }, +} + +const PUBLIC_BOARD = { + id: BOARD_ID, + name: 'Public Board', + slug: 'public-board', + isPublic: true, + deletedAt: null, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), +} + +const CREATED_POST = { + id: POST_ID, + title: 'My Feature Request', + content: 'Details here', + boardId: BOARD_ID, + createdAt: new Date('2024-06-01T12:00:00.000Z'), + updatedAt: new Date('2024-06-01T12:00:00.000Z'), + voteCount: 0, + statusId: null, +} + +function makePostRequest(body: unknown, token = 'valid-token'): Request { + return new Request('http://test/api/public/v1/posts', { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + }) +} + +// ---- tests ---------------------------------------------------------------- + +describe('POST /api/public/v1/posts', () => { + beforeEach(() => { + vi.clearAllMocks() + mockRequirePortalSession.mockResolvedValue(MOCK_SESSION) + mockGetBoardById.mockResolvedValue(PUBLIC_BOARD) + mockCreatePost.mockResolvedValue(CREATED_POST) + }) + + it('(a) anonymous request → 401 before any DB work', async () => { + mockRequirePortalSession.mockRejectedValue(new UnauthorizedError('Authentication required.')) + + const req = new Request('http://test/api/public/v1/posts', { method: 'POST' }) + const res = await POST({ request: req }) + + expect(res.status).toBe(401) + expect(mockCreatePost).not.toHaveBeenCalled() + expect(mockGetBoardById).not.toHaveBeenCalled() + }) + + it('(b) signed-in + valid body + public board → 201, createPost called with session principalId', async () => { + const res = await POST({ + request: makePostRequest({ + boardId: BOARD_ID, + title: 'My Feature Request', + content: 'Details here', + }), + }) + + expect(res.status).toBe(201) + const json = await res.json() + expect(json.data).toMatchObject({ + id: POST_ID, + title: 'My Feature Request', + boardId: BOARD_ID, + }) + expect(typeof json.data.createdAt).toBe('string') + + // createPost must be called with the session's principalId as authorPrincipalId + expect(mockCreatePost).toHaveBeenCalledOnce() + const [, authorArg] = mockCreatePost.mock.calls[0] + expect(authorArg.principalId).toBe(PRINCIPAL_ID) + }) + + it('(b2) content defaults to empty string when omitted', async () => { + await POST({ + request: makePostRequest({ boardId: BOARD_ID, title: 'No content' }), + }) + + expect(mockCreatePost).toHaveBeenCalledOnce() + const [inputArg] = mockCreatePost.mock.calls[0] + expect(inputArg.content).toBe('') + }) + + it('(c) invalid body — empty title → 400, createPost not called', async () => { + const res = await POST({ + request: makePostRequest({ boardId: BOARD_ID, title: '' }), + }) + + expect(res.status).toBe(400) + expect(mockCreatePost).not.toHaveBeenCalled() + }) + + it('(c2) invalid body — missing boardId → 400', async () => { + const res = await POST({ + request: makePostRequest({ title: 'Hello' }), + }) + + expect(res.status).toBe(400) + expect(mockCreatePost).not.toHaveBeenCalled() + }) + + it('(c3) invalid body — title over 200 chars → 400', async () => { + const res = await POST({ + request: makePostRequest({ boardId: BOARD_ID, title: 'x'.repeat(201) }), + }) + + expect(res.status).toBe(400) + expect(mockCreatePost).not.toHaveBeenCalled() + }) + + it('(d) board not found (getBoardById throws NotFoundError) → 404, createPost not called', async () => { + const { NotFoundError } = await import('@/lib/shared/errors') + mockGetBoardById.mockRejectedValue(new NotFoundError('BOARD_NOT_FOUND', 'Board not found')) + + const res = await POST({ + request: makePostRequest({ boardId: BOARD_ID, title: 'Hello' }), + }) + + expect(res.status).toBe(404) + const json = await res.json() + expect(json.error.message).toBe('Board not found') + expect(mockCreatePost).not.toHaveBeenCalled() + }) + + it('(d2) board exists but is not public → 404 (same as missing board), createPost not called', async () => { + mockGetBoardById.mockResolvedValue({ ...PUBLIC_BOARD, isPublic: false }) + + const res = await POST({ + request: makePostRequest({ boardId: BOARD_ID, title: 'Hello' }), + }) + + expect(res.status).toBe(404) + const json = await res.json() + expect(json.error.message).toBe('Board not found') + expect(mockCreatePost).not.toHaveBeenCalled() + }) + + it('(e) client-supplied authorPrincipalId in body is ignored — session principal is used', async () => { + const ATTACKER_PRINCIPAL = 'principal_attacker' as unknown as PrincipalId + + const res = await POST({ + request: makePostRequest({ + boardId: BOARD_ID, + title: 'Injected post', + content: 'Trying to set author', + authorPrincipalId: ATTACKER_PRINCIPAL, + author: ATTACKER_PRINCIPAL, + principalId: ATTACKER_PRINCIPAL, + }), + }) + + expect(res.status).toBe(201) + expect(mockCreatePost).toHaveBeenCalledOnce() + const [, authorArg] = mockCreatePost.mock.calls[0] + expect(authorArg.principalId).toBe(PRINCIPAL_ID) + expect(authorArg.principalId).not.toBe(ATTACKER_PRINCIPAL) + }) + + it('requirePortalSession is the very first async call (called before board lookup)', async () => { + // We verify ordering by having requirePortalSession reject; board + createPost must not run + mockRequirePortalSession.mockRejectedValue(new UnauthorizedError('Authentication required.')) + + await POST({ request: makePostRequest({ boardId: BOARD_ID, title: 'Hello' }) }) + + expect(mockRequirePortalSession).toHaveBeenCalledOnce() + expect(mockGetBoardById).not.toHaveBeenCalled() + expect(mockCreatePost).not.toHaveBeenCalled() + }) +}) diff --git a/apps/web/src/routes/api/public/v1/posts/__tests__/vote.test.ts b/apps/web/src/routes/api/public/v1/posts/__tests__/vote.test.ts new file mode 100644 index 000000000..2af084cd1 --- /dev/null +++ b/apps/web/src/routes/api/public/v1/posts/__tests__/vote.test.ts @@ -0,0 +1,102 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { PostId, PrincipalId } from '@opencoven-feedback/ids' +import { UnauthorizedError } from '@/lib/shared/errors' + +// ---- mocks ---------------------------------------------------------------- + +const mockRequirePortalSession = vi.fn() +const mockVoteOnPost = vi.fn() + +vi.mock('@tanstack/react-router', () => ({ + createFileRoute: vi.fn(() => (opts: unknown) => ({ options: opts })), +})) +vi.mock('@/lib/server/domains/api/portal-auth', () => ({ + requirePortalSession: (...args: unknown[]) => mockRequirePortalSession(...args), +})) +vi.mock('@/lib/server/domains/posts/post.voting', () => ({ + voteOnPost: (...args: unknown[]) => mockVoteOnPost(...args), +})) +vi.mock('@/lib/server/domains/api/validation', () => ({ + parseTypeId: (value: string) => value as T, +})) + +// ---- import route --------------------------------------------------------- + +import { Route } from '../$postId.vote' + +type RouteOpts = { + server: { + handlers: { + POST: (ctx: { request: Request; params: { postId: string } }) => Promise + } + } +} + +const POST = (Route as unknown as { options: RouteOpts }).options.server.handlers.POST + +// ---- fixtures ------------------------------------------------------------- + +const POST_ID = 'post_01kqhxq697fvgat0fn8rr1r7ea' as unknown as PostId +const PRINCIPAL_ID = 'principal_01kqhxq697fvgat0fn8rr1r7ew' as unknown as PrincipalId + +const MOCK_SESSION = { + user: { id: 'user_01', email: 'alice@example.com', name: 'Alice', image: null }, + principal: { id: PRINCIPAL_ID, role: 'user', type: 'user' }, +} + +const VOTE_RESULT = { voted: true, voteCount: 5 } + +function makeRequest(token = 'valid-token'): Request { + return new Request(`http://test/api/public/v1/posts/${POST_ID}/vote`, { + method: 'POST', + headers: { authorization: `Bearer ${token}` }, + }) +} + +// ---- tests ---------------------------------------------------------------- + +describe('POST /api/public/v1/posts/:postId/vote', () => { + beforeEach(() => { + vi.clearAllMocks() + mockRequirePortalSession.mockResolvedValue(MOCK_SESSION) + mockVoteOnPost.mockResolvedValue(VOTE_RESULT) + }) + + it('(a) anonymous request → 401, voteOnPost NOT called', async () => { + mockRequirePortalSession.mockRejectedValue(new UnauthorizedError('Authentication required.')) + + const res = await POST({ + request: new Request('http://test/api/public/v1/posts/some-post/vote', { method: 'POST' }), + params: { postId: String(POST_ID) }, + }) + + expect(res.status).toBe(401) + expect(mockVoteOnPost).not.toHaveBeenCalled() + }) + + it('(b) signed-in → 200, voteOnPost called with (postId, session.principal.id)', async () => { + const res = await POST({ + request: makeRequest(), + params: { postId: String(POST_ID) }, + }) + + expect(res.status).toBe(200) + expect(mockVoteOnPost).toHaveBeenCalledOnce() + const [calledPostId, calledPrincipalId] = mockVoteOnPost.mock.calls[0] + expect(calledPostId).toBe(POST_ID) + expect(calledPrincipalId).toBe(PRINCIPAL_ID) + }) + + it('(c) voted + voteCount are passed through in data', async () => { + mockVoteOnPost.mockResolvedValue({ voted: false, voteCount: 3 }) + + const res = await POST({ + request: makeRequest(), + params: { postId: String(POST_ID) }, + }) + + expect(res.status).toBe(200) + const json = await res.json() + expect(json.data).toMatchObject({ voted: false, voteCount: 3 }) + }) +}) diff --git a/apps/web/src/routes/api/public/v1/posts/index.ts b/apps/web/src/routes/api/public/v1/posts/index.ts new file mode 100644 index 000000000..8c6ab404c --- /dev/null +++ b/apps/web/src/routes/api/public/v1/posts/index.ts @@ -0,0 +1,120 @@ +import { createFileRoute } from '@tanstack/react-router' +import { z } from 'zod' +import type { BoardId } from '@opencoven-feedback/ids' +import { NotFoundError } from '@/lib/shared/errors' +import { + successResponse, + createdResponse, + badRequestResponse, + handleDomainError, +} from '@/lib/server/domains/api/responses' + +const submitPostSchema = z.object({ + boardId: z.string().min(1, 'Board ID is required'), + title: z.string().min(1, 'Title is required').max(200), + content: z.string().max(10000).optional().default(''), +}) + +export const Route = createFileRoute('/api/public/v1/posts/')({ + server: { + handlers: { + /** + * GET /api/public/v1/posts + * Anonymous feed — returns public posts with per-principal hasVoted flag. + */ + GET: async ({ request }) => { + try { + const url = new URL(request.url) + + const rawLimit = parseInt(url.searchParams.get('limit') ?? '20', 10) + const limit = Math.min(100, Math.max(1, isNaN(rawLimit) ? 20 : rawLimit)) + + const rawSort = url.searchParams.get('sort') ?? 'newest' + const sort: 'newest' | 'votes' = rawSort === 'votes' ? 'votes' : 'newest' + + const boardIdParam = url.searchParams.get('boardId') ?? undefined + const cursor = url.searchParams.get('cursor') ?? undefined + + const { isValidTypeId } = await import('@opencoven-feedback/ids') + const boardId = + boardIdParam && isValidTypeId(boardIdParam, 'board') + ? (boardIdParam as BoardId) + : undefined + + const { listPublicPostFeed } = await import('@/lib/server/domains/posts/post.public-list') + const result = await listPublicPostFeed({ + boardId, + sort, + cursor, + limit, + }) + + const { optionalPortalSession } = await import('@/lib/server/domains/api/portal-auth') + const session = await optionalPortalSession(request) + + let voted: Set = new Set() + if (session) { + const { getAllUserVotedPostIds } = + await import('@/lib/server/domains/posts/post.public') + voted = await getAllUserVotedPostIds(session.principal.id) + } + + return successResponse( + result.items.map((p) => ({ ...p, hasVoted: voted.has(p.id) })), + { pagination: { cursor: result.cursor, hasMore: result.hasMore } } + ) + } catch (error) { + return handleDomainError(error) + } + }, + + /** + * POST /api/public/v1/posts + * Authenticated end-user post submission. + * requirePortalSession runs first — anonymous requests are rejected + * with 401 before any body parsing or DB work. + */ + POST: async ({ request }) => { + try { + const { requirePortalSession } = await import('@/lib/server/domains/api/portal-auth') + const session = await requirePortalSession(request) + + const body = await request.json() + const parsed = submitPostSchema.safeParse(body) + if (!parsed.success) { + return badRequestResponse('Invalid request body', { + errors: parsed.error.flatten().fieldErrors, + }) + } + + const { getBoardById } = await import('@/lib/server/domains/boards/board.service') + const board = await getBoardById(parsed.data.boardId as BoardId) + if (!board.isPublic) { + throw new NotFoundError('BOARD_NOT_FOUND', 'Board not found') + } + + const { createPost } = await import('@/lib/server/domains/posts/post.service') + const result = await createPost( + { + boardId: parsed.data.boardId as BoardId, + title: parsed.data.title, + content: parsed.data.content, + }, + { + principalId: session.principal.id, + } + ) + + return createdResponse({ + id: result.id, + title: result.title, + boardId: result.boardId, + createdAt: result.createdAt.toISOString(), + }) + } catch (error) { + return handleDomainError(error) + } + }, + }, + }, +}) diff --git a/bun.lock b/bun.lock index 7f469a5c7..7e49b5cbf 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "quackback",