diff --git a/apps/web/src/lib/server/domains/posts/post.public.ts b/apps/web/src/lib/server/domains/posts/post.public.ts index 0bba019de..9cafc9dd6 100644 --- a/apps/web/src/lib/server/domains/posts/post.public.ts +++ b/apps/web/src/lib/server/domains/posts/post.public.ts @@ -84,6 +84,7 @@ export interface PostWithVotesAndAvatars { } interface PostListParams { + boardId?: import('@opencoven-feedback/ids').BoardId boardSlug?: string search?: string statusIds?: StatusId[] @@ -98,14 +99,16 @@ interface PostListParams { } function buildPostFilterConditions(params: PostListParams) { - const { boardSlug, statusIds, statusSlugs, tagIds, search } = params + const { boardId, boardSlug, statusIds, statusSlugs, tagIds, search } = params const conditions = [ eq(boards.isPublic, true), isNull(posts.canonicalPostId), isNull(posts.deletedAt), ] - if (boardSlug) { + if (boardId) { + conditions.push(eq(boards.id, boardId)) + } else if (boardSlug) { conditions.push(eq(boards.slug, boardSlug)) } diff --git a/apps/web/src/routes/api/public/v1/boards/__tests__/index.test.ts b/apps/web/src/routes/api/public/v1/boards/__tests__/index.test.ts new file mode 100644 index 000000000..572478aec --- /dev/null +++ b/apps/web/src/routes/api/public/v1/boards/__tests__/index.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockListPublicBoardsWithStats = vi.fn() + +vi.mock('@tanstack/react-router', () => ({ + createFileRoute: vi.fn(() => (opts: unknown) => ({ options: opts })), +})) + +vi.mock('@/lib/server/domains/boards/board.public', () => ({ + listPublicBoardsWithStats: (...args: unknown[]) => mockListPublicBoardsWithStats(...args), +})) + +import { Route } from '../index' + +type RouteOpts = { server: { handlers: { GET: (...args: unknown[]) => Promise } } } +const GET = (Route as unknown as { options: RouteOpts }).options.server.handlers.GET + +describe('GET /api/public/v1/boards', () => { + beforeEach(() => { + mockListPublicBoardsWithStats.mockReset() + }) + + it('returns public boards in the mobile SDK envelope', async () => { + mockListPublicBoardsWithStats.mockResolvedValue([ + { + id: 'board_01kqy4vw9jfg8v5z8r0pfjp8he', + name: 'Feedback', + slug: 'feedback', + description: 'Public feedback board', + postCount: 12, + }, + ]) + + const res = await GET({ request: new Request('http://test/api/public/v1/boards') }) + + expect(res.status).toBe(200) + expect(mockListPublicBoardsWithStats).toHaveBeenCalledWith() + await expect(res.json()).resolves.toEqual({ + data: [ + { + id: 'board_01kqy4vw9jfg8v5z8r0pfjp8he', + name: 'Feedback', + slug: 'feedback', + description: 'Public feedback board', + postCount: 12, + }, + ], + }) + }) +}) 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..72965e436 --- /dev/null +++ b/apps/web/src/routes/api/public/v1/boards/index.ts @@ -0,0 +1,28 @@ +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: async () => { + try { + const { listPublicBoardsWithStats } = + await import('@/lib/server/domains/boards/board.public') + const boards = await listPublicBoardsWithStats() + + return successResponse( + boards.map((board) => ({ + id: board.id, + name: board.name, + slug: board.slug, + description: board.description, + postCount: board.postCount, + })) + ) + } 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..20cf35f1e --- /dev/null +++ b/apps/web/src/routes/api/public/v1/changelog/__tests__/index.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockListPublicChangelogs = vi.fn() + +vi.mock('@tanstack/react-router', () => ({ + createFileRoute: vi.fn(() => (opts: unknown) => ({ options: opts })), +})) + +vi.mock('@/lib/server/domains/changelog/changelog.public', () => ({ + listPublicChangelogs: (...args: unknown[]) => mockListPublicChangelogs(...args), +})) + +import { Route } from '../index' + +type RouteOpts = { server: { handlers: { GET: (...args: unknown[]) => Promise } } } +const GET = (Route as unknown as { options: RouteOpts }).options.server.handlers.GET + +describe('GET /api/public/v1/changelog', () => { + beforeEach(() => { + mockListPublicChangelogs.mockReset() + }) + + it('returns published changelog entries in the mobile SDK page envelope', async () => { + mockListPublicChangelogs.mockResolvedValue({ + items: [ + { + id: 'changelog_01kqy4vw9jfg8v5z8r04aa4n5e', + title: 'iOS beta', + content: 'Mobile support shipped.', + publishedAt: new Date('2026-05-30T05:30:00.000Z'), + }, + ], + nextCursor: 'changelog_next', + hasMore: true, + }) + + const res = await GET({ + request: new Request('http://test/api/public/v1/changelog?cursor=changelog_prev'), + }) + + expect(res.status).toBe(200) + expect(mockListPublicChangelogs).toHaveBeenCalledWith({ + cursor: 'changelog_prev', + limit: 20, + }) + await expect(res.json()).resolves.toEqual({ + data: [ + { + id: 'changelog_01kqy4vw9jfg8v5z8r04aa4n5e', + title: 'iOS beta', + content: 'Mobile support shipped.', + publishedAt: '2026-05-30T05:30:00.000Z', + }, + ], + meta: { + pagination: { + cursor: 'changelog_next', + hasMore: true, + }, + }, + }) + }) +}) 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..28d528860 --- /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' + +export const Route = createFileRoute('/api/public/v1/changelog/')({ + server: { + handlers: { + GET: async ({ request }) => { + try { + const url = new URL(request.url) + const cursor = url.searchParams.get('cursor') ?? undefined + const limit = Math.min( + 100, + Math.max(1, Number.parseInt(url.searchParams.get('limit') ?? '20', 10) || 20) + ) + + const { listPublicChangelogs } = + await import('@/lib/server/domains/changelog/changelog.public') + const result = await listPublicChangelogs({ cursor, limit }) + + return successResponse( + result.items.map((entry) => ({ + id: entry.id, + title: entry.title, + content: entry.content, + publishedAt: entry.publishedAt.toISOString(), + })), + { + pagination: { + cursor: result.nextCursor, + hasMore: result.hasMore, + }, + } + ) + } catch (error) { + return handleDomainError(error) + } + }, + }, + }, +}) diff --git a/apps/web/src/routes/api/public/v1/help/__tests__/categories.test.ts b/apps/web/src/routes/api/public/v1/help/__tests__/categories.test.ts new file mode 100644 index 000000000..3737c7d14 --- /dev/null +++ b/apps/web/src/routes/api/public/v1/help/__tests__/categories.test.ts @@ -0,0 +1,47 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockListPublicCategories = vi.fn() + +vi.mock('@tanstack/react-router', () => ({ + createFileRoute: vi.fn(() => (opts: unknown) => ({ options: opts })), +})) + +vi.mock('@/lib/server/domains/help-center/help-center.category.service', () => ({ + listPublicCategories: (...args: unknown[]) => mockListPublicCategories(...args), +})) + +import { Route } from '../categories' + +type RouteOpts = { server: { handlers: { GET: (...args: unknown[]) => Promise } } } +const GET = (Route as unknown as { options: RouteOpts }).options.server.handlers.GET + +describe('GET /api/public/v1/help/categories', () => { + beforeEach(() => { + mockListPublicCategories.mockReset() + }) + + it('returns public categories in the mobile SDK envelope', async () => { + mockListPublicCategories.mockResolvedValue([ + { + id: 'category_01kqy4vw9jfg8v5z8r0fvkgw8h', + name: 'Getting Started', + slug: 'getting-started', + description: 'Basics', + }, + ]) + + const res = await GET({ request: new Request('http://test/api/public/v1/help/categories') }) + + expect(res.status).toBe(200) + await expect(res.json()).resolves.toEqual({ + data: [ + { + id: 'category_01kqy4vw9jfg8v5z8r0fvkgw8h', + name: 'Getting Started', + slug: 'getting-started', + description: 'Basics', + }, + ], + }) + }) +}) 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..ad2c7a66e --- /dev/null +++ b/apps/web/src/routes/api/public/v1/help/articles/$slug.ts @@ -0,0 +1,34 @@ +import { createFileRoute } from '@tanstack/react-router' +import { + successResponse, + notFoundResponse, + handleDomainError, +} from '@/lib/server/domains/api/responses' + +export const Route = createFileRoute('/api/public/v1/help/articles/$slug')({ + server: { + handlers: { + GET: async ({ params }) => { + try { + const { getPublicArticleBySlug } = + await import('@/lib/server/domains/help-center/help-center.article.service') + const article = await getPublicArticleBySlug(params.slug) + + if (!article) { + return notFoundResponse('Help center article') + } + + 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/articles/__tests__/$slug.test.ts b/apps/web/src/routes/api/public/v1/help/articles/__tests__/$slug.test.ts new file mode 100644 index 000000000..b1e9f9cdd --- /dev/null +++ b/apps/web/src/routes/api/public/v1/help/articles/__tests__/$slug.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockGetPublicArticleBySlug = vi.fn() + +vi.mock('@tanstack/react-router', () => ({ + createFileRoute: vi.fn(() => (opts: unknown) => ({ options: opts })), +})) + +vi.mock('@/lib/server/domains/help-center/help-center.article.service', () => ({ + getPublicArticleBySlug: (...args: unknown[]) => mockGetPublicArticleBySlug(...args), +})) + +import { Route } from '../$slug' + +type RouteOpts = { server: { handlers: { GET: (...args: unknown[]) => Promise } } } +const GET = (Route as unknown as { options: RouteOpts }).options.server.handlers.GET + +describe('GET /api/public/v1/help/articles/:slug', () => { + beforeEach(() => { + mockGetPublicArticleBySlug.mockReset() + }) + + it('returns a public help article in the mobile SDK envelope', async () => { + mockGetPublicArticleBySlug.mockResolvedValue({ + id: 'article_01kqy4vw9jfg8v5z8r07c1ezp', + slug: 'install', + title: 'Install', + content: 'Install the SDK.', + categoryId: 'category_01kqy4vw9jfg8v5z8r0fvkgw8h', + }) + + const res = await GET({ + request: new Request('http://test/api/public/v1/help/articles/install'), + params: { slug: 'install' }, + }) + + expect(res.status).toBe(200) + expect(mockGetPublicArticleBySlug).toHaveBeenCalledWith('install') + await expect(res.json()).resolves.toEqual({ + data: { + id: 'article_01kqy4vw9jfg8v5z8r07c1ezp', + slug: 'install', + title: 'Install', + content: 'Install the SDK.', + categoryId: 'category_01kqy4vw9jfg8v5z8r0fvkgw8h', + }, + }) + }) +}) diff --git a/apps/web/src/routes/api/public/v1/help/categories.ts b/apps/web/src/routes/api/public/v1/help/categories.ts new file mode 100644 index 000000000..8424e2453 --- /dev/null +++ b/apps/web/src/routes/api/public/v1/help/categories.ts @@ -0,0 +1,27 @@ +import { createFileRoute } from '@tanstack/react-router' +import { successResponse, handleDomainError } from '@/lib/server/domains/api/responses' + +export const Route = createFileRoute('/api/public/v1/help/categories')({ + server: { + handlers: { + GET: async () => { + try { + const { listPublicCategories } = + await import('@/lib/server/domains/help-center/help-center.category.service') + const categories = await listPublicCategories() + + return successResponse( + categories.map((category) => ({ + id: category.id, + name: category.name, + slug: category.slug, + description: category.description, + })) + ) + } catch (error) { + return handleDomainError(error) + } + }, + }, + }, +}) 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..dbbf102c1 --- /dev/null +++ b/apps/web/src/routes/api/public/v1/posts/$postId.comments.ts @@ -0,0 +1,40 @@ +import { createFileRoute } from '@tanstack/react-router' +import { + successResponse, + notFoundResponse, + handleDomainError, +} from '@/lib/server/domains/api/responses' +import type { PostId } from '@opencoven-feedback/ids' +import type { PublicComment } from '@/lib/server/domains/posts/post.types' + +function serializeComment(comment: PublicComment): unknown { + return { + id: comment.id, + content: comment.content, + authorName: comment.authorName ?? '', + createdAt: comment.createdAt.toISOString(), + replies: comment.replies.map(serializeComment), + } +} + +export const Route = createFileRoute('/api/public/v1/posts/$postId/comments')({ + server: { + handlers: { + GET: async ({ params }) => { + try { + const { getPublicPostDetail } = + await import('@/lib/server/domains/posts/post.public.detail') + const post = await getPublicPostDetail(params.postId as PostId) + + if (!post) { + return notFoundResponse('Post') + } + + return successResponse(post.comments.map(serializeComment)) + } 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..27251bc9b --- /dev/null +++ b/apps/web/src/routes/api/public/v1/posts/$postId.ts @@ -0,0 +1,38 @@ +import { createFileRoute } from '@tanstack/react-router' +import { + successResponse, + notFoundResponse, + handleDomainError, +} from '@/lib/server/domains/api/responses' +import type { PostId } from '@opencoven-feedback/ids' + +export const Route = createFileRoute('/api/public/v1/posts/$postId')({ + server: { + handlers: { + GET: async ({ params }) => { + try { + const { getPublicPostDetail } = + await import('@/lib/server/domains/posts/post.public.detail') + const post = await getPublicPostDetail(params.postId as PostId) + + if (!post) { + return notFoundResponse('Post') + } + + return successResponse({ + id: post.id, + title: post.title, + content: post.content, + voteCount: post.voteCount, + statusId: post.statusId, + boardId: post.board.id, + createdAt: post.createdAt.toISOString(), + hasVoted: false, + }) + } catch (error) { + return handleDomainError(error) + } + }, + }, + }, +}) diff --git a/apps/web/src/routes/api/public/v1/posts/__tests__/$postId.comments.test.ts b/apps/web/src/routes/api/public/v1/posts/__tests__/$postId.comments.test.ts new file mode 100644 index 000000000..5b687f319 --- /dev/null +++ b/apps/web/src/routes/api/public/v1/posts/__tests__/$postId.comments.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockGetPublicPostDetail = vi.fn() + +vi.mock('@tanstack/react-router', () => ({ + createFileRoute: vi.fn(() => (opts: unknown) => ({ options: opts })), +})) + +vi.mock('@/lib/server/domains/posts/post.public.detail', () => ({ + getPublicPostDetail: (...args: unknown[]) => mockGetPublicPostDetail(...args), +})) + +import { Route } from '../$postId.comments' + +type RouteOpts = { server: { handlers: { GET: (...args: unknown[]) => Promise } } } +const GET = (Route as unknown as { options: RouteOpts }).options.server.handlers.GET + +describe('GET /api/public/v1/posts/:postId/comments', () => { + beforeEach(() => { + mockGetPublicPostDetail.mockReset() + }) + + it('returns public comments in the mobile SDK envelope', async () => { + mockGetPublicPostDetail.mockResolvedValue({ + comments: [ + { + id: 'comment_01kqy4vw9jfg8v5z8r0t8624hd', + content: 'Agreed', + authorName: 'Val', + createdAt: new Date('2026-05-30T05:35:00.000Z'), + replies: [], + }, + ], + }) + + const res = await GET({ + request: new Request( + 'http://test/api/public/v1/posts/post_01kqy4vw9jfg8v5z8r0k2tqjda/comments' + ), + params: { postId: 'post_01kqy4vw9jfg8v5z8r0k2tqjda' }, + }) + + expect(res.status).toBe(200) + expect(mockGetPublicPostDetail).toHaveBeenCalledWith('post_01kqy4vw9jfg8v5z8r0k2tqjda') + await expect(res.json()).resolves.toEqual({ + data: [ + { + id: 'comment_01kqy4vw9jfg8v5z8r0t8624hd', + content: 'Agreed', + authorName: 'Val', + createdAt: '2026-05-30T05:35:00.000Z', + replies: [], + }, + ], + }) + }) +}) diff --git a/apps/web/src/routes/api/public/v1/posts/__tests__/$postId.test.ts b/apps/web/src/routes/api/public/v1/posts/__tests__/$postId.test.ts new file mode 100644 index 000000000..491cc0649 --- /dev/null +++ b/apps/web/src/routes/api/public/v1/posts/__tests__/$postId.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockGetPublicPostDetail = vi.fn() + +vi.mock('@tanstack/react-router', () => ({ + createFileRoute: vi.fn(() => (opts: unknown) => ({ options: opts })), +})) + +vi.mock('@/lib/server/domains/posts/post.public.detail', () => ({ + getPublicPostDetail: (...args: unknown[]) => mockGetPublicPostDetail(...args), +})) + +import { Route } from '../$postId' + +type RouteOpts = { server: { handlers: { GET: (...args: unknown[]) => Promise } } } +const GET = (Route as unknown as { options: RouteOpts }).options.server.handlers.GET + +describe('GET /api/public/v1/posts/:postId', () => { + beforeEach(() => { + mockGetPublicPostDetail.mockReset() + }) + + it('returns a mobile SDK compatible public post detail envelope', async () => { + mockGetPublicPostDetail.mockResolvedValue({ + id: 'post_01kqy4vw9jfg8v5z8r0k2tqjda', + title: 'Ship iOS support', + content: 'Native users need this.', + voteCount: 12, + statusId: null, + createdAt: new Date('2026-05-30T05:30:00.000Z'), + board: { + id: 'board_01kqy4vw9jfg8v5z8r0pfjp8he', + name: 'Feedback', + slug: 'feedback', + }, + comments: [], + }) + + const res = await GET({ + request: new Request('http://test/api/public/v1/posts/post_01kqy4vw9jfg8v5z8r0k2tqjda'), + params: { postId: 'post_01kqy4vw9jfg8v5z8r0k2tqjda' }, + }) + + expect(res.status).toBe(200) + expect(mockGetPublicPostDetail).toHaveBeenCalledWith('post_01kqy4vw9jfg8v5z8r0k2tqjda') + await expect(res.json()).resolves.toEqual({ + data: { + id: 'post_01kqy4vw9jfg8v5z8r0k2tqjda', + title: 'Ship iOS support', + content: 'Native users need this.', + voteCount: 12, + statusId: null, + boardId: 'board_01kqy4vw9jfg8v5z8r0pfjp8he', + createdAt: '2026-05-30T05:30:00.000Z', + hasVoted: false, + }, + }) + }) +}) 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..bb01ef614 --- /dev/null +++ b/apps/web/src/routes/api/public/v1/posts/__tests__/index.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockListPublicPosts = vi.fn() + +vi.mock('@tanstack/react-router', () => ({ + createFileRoute: vi.fn(() => (opts: unknown) => ({ options: opts })), +})) + +vi.mock('@/lib/server/domains/posts/post.public', () => ({ + listPublicPosts: (...args: unknown[]) => mockListPublicPosts(...args), +})) + +import { Route } from '../index' + +type RouteOpts = { server: { handlers: { GET: (...args: unknown[]) => Promise } } } +const GET = (Route as unknown as { options: RouteOpts }).options.server.handlers.GET + +describe('GET /api/public/v1/posts', () => { + beforeEach(() => { + mockListPublicPosts.mockReset() + }) + + it('returns mobile SDK compatible public posts without API key auth', async () => { + mockListPublicPosts.mockResolvedValue({ + items: [ + { + id: 'post_01kqy4vw9jfg8v5z8r0k2tqjda', + title: 'Ship iOS support', + content: 'Native users need this.', + statusId: 'status_01kqy4vw9jfg8v5z8r0bc0k21', + voteCount: 12, + authorName: 'Val', + principalId: 'principal_01kqy4vw9jfg8v5z8r0z6tvfaa', + createdAt: new Date('2026-05-30T05:30:00.000Z'), + commentCount: 3, + tags: [], + board: { + id: 'board_01kqy4vw9jfg8v5z8r0pfjp8he', + name: 'Feedback', + slug: 'feedback', + }, + }, + ], + total: -1, + hasMore: true, + }) + + const res = await GET({ + request: new Request( + 'http://test/api/public/v1/posts?boardId=board_01kqy4vw9jfg8v5z8r0pfjp8he&sort=newest&cursor=2' + ), + }) + + expect(res.status).toBe(200) + expect(mockListPublicPosts).toHaveBeenCalledWith({ + boardId: 'board_01kqy4vw9jfg8v5z8r0pfjp8he', + sort: 'new', + page: 2, + limit: 20, + }) + await expect(res.json()).resolves.toEqual({ + data: [ + { + id: 'post_01kqy4vw9jfg8v5z8r0k2tqjda', + title: 'Ship iOS support', + voteCount: 12, + statusId: 'status_01kqy4vw9jfg8v5z8r0bc0k21', + boardId: 'board_01kqy4vw9jfg8v5z8r0pfjp8he', + createdAt: '2026-05-30T05:30:00.000Z', + hasVoted: false, + }, + ], + meta: { + pagination: { + cursor: '3', + hasMore: true, + }, + }, + }) + }) +}) 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..b4db73a36 --- /dev/null +++ b/apps/web/src/routes/api/public/v1/posts/index.ts @@ -0,0 +1,79 @@ +import { createFileRoute } from '@tanstack/react-router' +import { successResponse, handleDomainError } from '@/lib/server/domains/api/responses' +import type { BoardId, StatusId, TagId } from '@opencoven-feedback/ids' + +function parsePage(cursor: string | null): number { + if (!cursor) return 1 + const parsed = Number.parseInt(cursor, 10) + return Number.isFinite(parsed) && parsed > 0 ? parsed : 1 +} + +function mapSort(sort: string | null): 'top' | 'new' | 'trending' { + switch (sort) { + case 'newest': + case 'new': + return 'new' + case 'trending': + return 'trending' + case 'votes': + case 'top': + default: + return 'top' + } +} + +export const Route = createFileRoute('/api/public/v1/posts/')({ + server: { + handlers: { + GET: async ({ request }) => { + try { + const url = new URL(request.url) + const page = parsePage(url.searchParams.get('cursor')) + const limit = Math.min( + 100, + Math.max(1, Number.parseInt(url.searchParams.get('limit') ?? '20', 10) || 20) + ) + const boardId = url.searchParams.get('boardId') || undefined + const boardSlug = url.searchParams.get('boardSlug') || undefined + const search = url.searchParams.get('search') || undefined + const statusId = url.searchParams.get('statusId') || undefined + const statusSlug = url.searchParams.get('status') || undefined + const tagIds = url.searchParams.get('tagIds') || undefined + + const { listPublicPosts } = await import('@/lib/server/domains/posts/post.public') + const result = await listPublicPosts({ + boardId: boardId as BoardId | undefined, + boardSlug, + search, + statusIds: statusId ? ([statusId] as StatusId[]) : undefined, + statusSlugs: statusSlug ? [statusSlug] : undefined, + tagIds: tagIds ? (tagIds.split(',').filter(Boolean) as TagId[]) : undefined, + sort: mapSort(url.searchParams.get('sort')), + page, + limit, + }) + + return successResponse( + result.items.map((post) => ({ + id: post.id, + title: post.title, + voteCount: post.voteCount, + statusId: post.statusId, + boardId: post.board?.id, + createdAt: post.createdAt.toISOString(), + hasVoted: false, + })), + { + pagination: { + cursor: result.hasMore ? String(page + 1) : null, + hasMore: result.hasMore, + }, + } + ) + } catch (error) { + return handleDomainError(error) + } + }, + }, + }, +})