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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ All notable changes to `doc` are documented here.

### Added

- Extend document search to content body and add time-range and sort filters (#68): both
`GET /api/doc?keyword=` and `GET /api/v1/documents?query=` now match title OR content
(case-insensitive). New `after` / `before` (ISO 8601) filter by `updatedAt`; `sort` accepts
`updated_desc` (default), `updated_asc`, `created_desc`, `created_asc`. Shared query parser
(`src/lib/doc-query.ts`) keeps the two routes consistent; invalid dates/sort return 400.

- Adoption of the shared `@fullstack-ai-infra/ui` design system for the first document
workflow: workspace shell (responsive sidebar with compact mode), editor chrome, and
landing/sign-in surfaces consume shared tokens and components; adds a Playwright
Expand Down
68 changes: 67 additions & 1 deletion src/__tests__/lib/api-v1-documents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,14 +184,80 @@ describe('v1 document service', () => {
userId: 'user-1',
isDeleted: false,
isStar: false,
title: { contains: 'Example', mode: 'insensitive' },
OR: [
{ title: { contains: 'Example', mode: 'insensitive' } },
{ content: { contains: 'Example', mode: 'insensitive' } },
],
}),
orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }],
take: 2,
})
)
})

test('searches document content when query does not match title', async () => {
mocks.findMany.mockResolvedValue([])

await listApiDocuments('user-1', new URLSearchParams({ query: 'bodykeyword' }))

expect(mocks.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
OR: [
{ title: { contains: 'bodykeyword', mode: 'insensitive' } },
{ content: { contains: 'bodykeyword', mode: 'insensitive' } },
],
}),
})
)
})

test('filters by time range with after and before params', async () => {
mocks.findMany.mockResolvedValue([])

await listApiDocuments(
'user-1',
new URLSearchParams({ after: '2026-09-01T00:00:00.000Z', before: '2026-09-17T00:00:00.000Z' })
)

expect(mocks.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
updatedAt: {
gt: new Date('2026-09-01T00:00:00.000Z'),
lt: new Date('2026-09-17T00:00:00.000Z'),
},
}),
})
)
})

test('sorts by created_asc when requested', async () => {
mocks.findMany.mockResolvedValue([])

await listApiDocuments('user-1', new URLSearchParams({ sort: 'created_asc' }))

expect(mocks.findMany).toHaveBeenCalledWith(
expect.objectContaining({
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
})
)
})

test('rejects invalid after date', async () => {
await expect(listApiDocuments('user-1', new URLSearchParams({ after: 'not-a-date' }))).rejects.toMatchObject({
status: 400,
code: 'invalid_query',
})
})

test('rejects invalid sort value', async () => {
await expect(listApiDocuments('user-1', new URLSearchParams({ sort: 'invalid' }))).rejects.toMatchObject({
status: 400,
code: 'invalid_query',
})
})

test('hides inaccessible documents and permits explicit read shares', async () => {
mocks.findFirst.mockResolvedValueOnce({
...metadata,
Expand Down
38 changes: 32 additions & 6 deletions src/app/api/doc/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { getNextSortOrderForParent } from '@/lib/doc-sort-order'
import { JsonBodyError, readJsonBody } from '@/lib/read-json-body'
import { ApiV1Error } from '@/lib/api-v1'
import { EMPTY_TIPTAP_DOCUMENT, encodeTiptapDocument } from '@/lib/tiptap-codec'
import { parseOptionalDate, parseSort, buildSearchWhere, buildDateWhere, getOrderBy, SortValue } from '@/lib/doc-query'

const MAX_CREATE_REQUEST_BYTES = 1024 * 1024

Expand Down Expand Up @@ -210,6 +211,30 @@ export async function GET(request: NextRequest) {
// 搜索关键字
const keyword = searchParams.get('keyword') || null

// 时间范围过滤
let afterDate: Date | null = null
let beforeDate: Date | null = null
try {
afterDate = parseOptionalDate(searchParams.get('after'), 'after')
beforeDate = parseOptionalDate(searchParams.get('before'), 'before')
} catch (error) {
if (error instanceof ApiV1Error) {
return Response.json(genErrorData(error.message), { status: error.status })
}
throw error
}

// 排序
let sort: SortValue
try {
sort = parseSort(searchParams.get('sort'))
} catch (error) {
if (error instanceof ApiV1Error) {
return Response.json(genErrorData(error.message), { status: error.status })
}
throw error
}

// where
const whereOpt: any = {
isDeleted: false, // 默认
Expand All @@ -229,11 +254,14 @@ export async function GET(request: NextRequest) {
}
}
if (keyword != null) {
whereOpt.title = {
contains: keyword,
}
Object.assign(whereOpt, buildSearchWhere(keyword))
}
if (afterDate || beforeDate) {
Object.assign(whereOpt, buildDateWhere(afterDate, beforeDate))
}

const orderBy = getOrderBy(sort)

const list = await db.doc.findMany({
select: {
id: true,
Expand All @@ -247,9 +275,7 @@ export async function GET(request: NextRequest) {
userId: user.id || '',
...whereOpt,
},
orderBy: {
updatedAt: 'desc',
},
orderBy,
})

return Response.json(genSuccessData(list || []))
Expand Down
15 changes: 13 additions & 2 deletions src/lib/api-v1-documents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { db } from '@/db/db'
import { ApiV1Error } from '@/lib/api-v1'
import { getNextSortOrderForParent } from '@/lib/doc-sort-order'
import { EMPTY_TIPTAP_DOCUMENT, encodeTiptapDocument } from '@/lib/tiptap-codec'
import { parseOptionalDate, parseSort, buildSearchWhere, buildDateWhere, getOrderBy, SortValue } from '@/lib/doc-query'

const DEFAULT_LIST_LIMIT = 50
const MAX_LIST_LIMIT = 100
Expand Down Expand Up @@ -163,15 +164,23 @@ export async function listApiDocuments(userId: string, searchParams: URLSearchPa
const query = searchParams.get('query')?.trim() || ''
if (query.length > 200) throw new ApiV1Error(400, 'invalid_query', 'query must not exceed 200 characters')

const after = parseOptionalDate(searchParams.get('after'), 'after')
const before = parseOptionalDate(searchParams.get('before'), 'before')
const sort = parseSort(searchParams.get('sort'))

const where: Prisma.DocWhereInput = {
userId,
isDeleted: trash,
...(starred === undefined ? {} : { isStar: starred }),
...(query ? { title: { contains: query, mode: 'insensitive' } } : {}),
...buildSearchWhere(query),
...buildDateWhere(after, before),
}

const cursorValue = searchParams.get('cursor')
if (cursorValue) {
if (sort === 'created_desc' || sort === 'created_asc') {
throw new ApiV1Error(400, 'invalid_cursor', 'Cursor pagination is not supported for created_* sort orders')
}
const cursor = decodeCursor(cursorValue)
const cursorDate = new Date(cursor.updatedAt)
where.AND = [
Expand All @@ -181,10 +190,12 @@ export async function listApiDocuments(userId: string, searchParams: URLSearchPa
]
}

const orderBy = getOrderBy(sort)

const rows = await db.doc.findMany({
where,
select: documentMetadataSelect,
orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }],
orderBy,
take: limit + 1,
})
const hasMore = rows.length > limit
Expand Down
55 changes: 55 additions & 0 deletions src/lib/doc-query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import 'server-only'

import { Prisma } from '@prisma/client'
import { ApiV1Error } from '@/lib/api-v1'

const VALID_SORT_VALUES = ['updated_desc', 'updated_asc', 'created_desc', 'created_asc'] as const
export type SortValue = (typeof VALID_SORT_VALUES)[number]

export function parseOptionalDate(value: string | null, name: string): Date | null {
if (value == null) return null
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
throw new ApiV1Error(400, 'invalid_query', `${name} must be an ISO 8601 date`)
}
return date
}

export function parseSort(value: string | null): SortValue {
if (value == null) return 'updated_desc'
if (!VALID_SORT_VALUES.includes(value as SortValue)) {
throw new ApiV1Error(400, 'invalid_query', `sort must be one of: ${VALID_SORT_VALUES.join(', ')}`)
}
return value as SortValue
}

export function buildSearchWhere(query: string): Prisma.DocWhereInput {
if (!query) return {}
return {
OR: [{ title: { contains: query, mode: 'insensitive' } }, { content: { contains: query, mode: 'insensitive' } }],
}
}

export function buildDateWhere(after: Date | null, before: Date | null): Prisma.DocWhereInput {
if (!after && !before) return {}
return {
updatedAt: {
...(after ? { gt: after } : {}),
...(before ? { lt: before } : {}),
},
}
}

export function getOrderBy(sort: SortValue): Prisma.DocOrderByWithRelationInput[] {
switch (sort) {
case 'updated_asc':
return [{ updatedAt: 'asc' }, { id: 'asc' }]
case 'created_desc':
return [{ createdAt: 'desc' }, { id: 'desc' }]
case 'created_asc':
return [{ createdAt: 'asc' }, { id: 'asc' }]
case 'updated_desc':
default:
return [{ updatedAt: 'desc' }, { id: 'desc' }]
}
}
Loading