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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions apps/web/src/lib/server/domains/posts/post.public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export interface PostWithVotesAndAvatars {
}

interface PostListParams {
boardId?: import('@opencoven-feedback/ids').BoardId
boardSlug?: string
search?: string
statusIds?: StatusId[]
Expand All @@ -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))
}

Expand Down
50 changes: 50 additions & 0 deletions apps/web/src/routes/api/public/v1/boards/__tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -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<Response> } } }
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,
},
],
})
})
})
28 changes: 28 additions & 0 deletions apps/web/src/routes/api/public/v1/boards/index.ts
Original file line number Diff line number Diff line change
@@ -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)
}
},
},
},
})
Original file line number Diff line number Diff line change
@@ -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<Response> } } }
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,
},
},
})
})
})
40 changes: 40 additions & 0 deletions apps/web/src/routes/api/public/v1/changelog/index.ts
Original file line number Diff line number Diff line change
@@ -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)
}
},
},
},
})
Original file line number Diff line number Diff line change
@@ -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<Response> } } }
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',
},
],
})
})
})
34 changes: 34 additions & 0 deletions apps/web/src/routes/api/public/v1/help/articles/$slug.ts
Original file line number Diff line number Diff line change
@@ -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)
}
},
},
},
})
Original file line number Diff line number Diff line change
@@ -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<Response> } } }
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',
},
})
})
})
27 changes: 27 additions & 0 deletions apps/web/src/routes/api/public/v1/help/categories.ts
Original file line number Diff line number Diff line change
@@ -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)
}
},
},
},
})
Loading
Loading