From db8ecb857e4fc771c51160a9755635334c8acda8 Mon Sep 17 00:00:00 2001 From: user Date: Thu, 17 Sep 2026 21:03:17 +0800 Subject: [PATCH 1/4] feat(search): extend document search to content + add time range and sort filters (#68) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Internal GET /api/doc and API v1 GET /api/v1/documents now search both title and content (OR) instead of title-only - Add after/before query params for updatedAt range filtering - Add sort query param (updated_desc|updated_asc|created_desc|created_asc) - No schema migration needed — uses Prisma OR + contains --- src/__tests__/lib/api-v1-documents.test.ts | 68 +++++++++++++++++++++- src/app/api/doc/route.ts | 29 +++++++-- src/lib/api-v1-documents.ts | 58 +++++++++++++++++- 3 files changed, 147 insertions(+), 8 deletions(-) diff --git a/src/__tests__/lib/api-v1-documents.test.ts b/src/__tests__/lib/api-v1-documents.test.ts index aa5bc8f..fdf39b7 100644 --- a/src/__tests__/lib/api-v1-documents.test.ts +++ b/src/__tests__/lib/api-v1-documents.test.ts @@ -184,7 +184,10 @@ 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, @@ -192,6 +195,69 @@ describe('v1 document service', () => { ) }) + 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, diff --git a/src/app/api/doc/route.ts b/src/app/api/doc/route.ts index 8553dbb..ca3a1fe 100644 --- a/src/app/api/doc/route.ts +++ b/src/app/api/doc/route.ts @@ -210,6 +210,15 @@ export async function GET(request: NextRequest) { // 搜索关键字 const keyword = searchParams.get('keyword') || null + // 时间范围过滤 + const afterParam = searchParams.get('after') + const beforeParam = searchParams.get('before') + const afterDate = afterParam ? new Date(afterParam) : null + const beforeDate = beforeParam ? new Date(beforeParam) : null + + // 排序 + const sortParam = searchParams.get('sort') || 'updated_desc' + // where const whereOpt: any = { isDeleted: false, // 默认 @@ -229,11 +238,23 @@ export async function GET(request: NextRequest) { } } if (keyword != null) { - whereOpt.title = { - contains: keyword, + whereOpt.OR = [{ title: { contains: keyword } }, { content: { contains: keyword, mode: 'insensitive' } }] + } + if (afterDate || beforeDate) { + whereOpt.updatedAt = { + ...(afterDate && !isNaN(afterDate.getTime()) ? { gt: afterDate } : {}), + ...(beforeDate && !isNaN(beforeDate.getTime()) ? { lt: beforeDate } : {}), } } + const orderByMap: Record = { + updated_desc: { updatedAt: 'desc' }, + updated_asc: { updatedAt: 'asc' }, + created_desc: { createdAt: 'desc' }, + created_asc: { createdAt: 'asc' }, + } + const orderBy = orderByMap[sortParam] || orderByMap.updated_desc + const list = await db.doc.findMany({ select: { id: true, @@ -247,9 +268,7 @@ export async function GET(request: NextRequest) { userId: user.id || '', ...whereOpt, }, - orderBy: { - updatedAt: 'desc', - }, + orderBy, }) return Response.json(genSuccessData(list || [])) diff --git a/src/lib/api-v1-documents.ts b/src/lib/api-v1-documents.ts index 6bdacb8..1472670 100644 --- a/src/lib/api-v1-documents.ts +++ b/src/lib/api-v1-documents.ts @@ -11,6 +11,8 @@ import { EMPTY_TIPTAP_DOCUMENT, encodeTiptapDocument } from '@/lib/tiptap-codec' const DEFAULT_LIST_LIMIT = 50 const MAX_LIST_LIMIT = 100 +const VALID_SORT_VALUES = ['updated_desc', 'updated_asc', 'created_desc', 'created_asc'] as const +type SortValue = (typeof VALID_SORT_VALUES)[number] const titleSchema = z .string() @@ -121,6 +123,37 @@ function parseListLimit(value: string | null) { return limit } +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 +} + +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 +} + +function getOrderBy(sort: SortValue) { + switch (sort) { + case 'updated_asc': + return [{ updatedAt: 'asc' }, { id: 'asc' }] as const + case 'created_desc': + return [{ createdAt: 'desc' }, { id: 'desc' }] as const + case 'created_asc': + return [{ createdAt: 'asc' }, { id: 'asc' }] as const + case 'updated_desc': + default: + return [{ updatedAt: 'desc' }, { id: 'desc' }] as const + } +} + export function apiDocumentEtag(document: Pick) { const revision = createHash('sha256') .update(`${document.id}:${document.updatedAt.toISOString()}`) @@ -163,11 +196,30 @@ 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' } } : {}), + ...(query + ? { + OR: [ + { title: { contains: query, mode: 'insensitive' } }, + { content: { contains: query, mode: 'insensitive' } }, + ], + } + : {}), + ...(after || before + ? { + updatedAt: { + ...(after ? { gt: after } : {}), + ...(before ? { lt: before } : {}), + }, + } + : {}), } const cursorValue = searchParams.get('cursor') @@ -181,10 +233,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 From c013bfd30205eb41c0f3ffd2e807c08d5fdead24 Mon Sep 17 00:00:00 2001 From: user Date: Fri, 18 Sep 2026 09:49:40 +0800 Subject: [PATCH 2/4] fix(search): address review feedback for search, validation, and cursor pagination - Extract shared doc-query.ts module for query parsing and where/orderBy builders - Fix getOrderBy to return mutable array (TS2322 build failure) - Add mode: insensitive to /api/doc title search for consistency with v1 - Return 400 for invalid date/sort params instead of silently ignoring - Disable cursor pagination for created_* sort orders (cursor is updatedAt-based) - Unify search/date/sort logic between /api/doc and /api/v1/documents --- src/app/api/doc/route.ts | 41 +++++++++++++++----------- src/lib/api-v1-documents.ts | 55 ++++------------------------------- src/lib/doc-query.ts | 58 +++++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 66 deletions(-) create mode 100644 src/lib/doc-query.ts diff --git a/src/app/api/doc/route.ts b/src/app/api/doc/route.ts index ca3a1fe..0647b43 100644 --- a/src/app/api/doc/route.ts +++ b/src/app/api/doc/route.ts @@ -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 @@ -211,13 +212,28 @@ export async function GET(request: NextRequest) { const keyword = searchParams.get('keyword') || null // 时间范围过滤 - const afterParam = searchParams.get('after') - const beforeParam = searchParams.get('before') - const afterDate = afterParam ? new Date(afterParam) : null - const beforeDate = beforeParam ? new Date(beforeParam) : 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 + } // 排序 - const sortParam = searchParams.get('sort') || 'updated_desc' + 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 = { @@ -238,22 +254,13 @@ export async function GET(request: NextRequest) { } } if (keyword != null) { - whereOpt.OR = [{ title: { contains: keyword } }, { content: { contains: keyword, mode: 'insensitive' } }] + Object.assign(whereOpt, buildSearchWhere(keyword)) } if (afterDate || beforeDate) { - whereOpt.updatedAt = { - ...(afterDate && !isNaN(afterDate.getTime()) ? { gt: afterDate } : {}), - ...(beforeDate && !isNaN(beforeDate.getTime()) ? { lt: beforeDate } : {}), - } + Object.assign(whereOpt, buildDateWhere(afterDate, beforeDate)) } - const orderByMap: Record = { - updated_desc: { updatedAt: 'desc' }, - updated_asc: { updatedAt: 'asc' }, - created_desc: { createdAt: 'desc' }, - created_asc: { createdAt: 'asc' }, - } - const orderBy = orderByMap[sortParam] || orderByMap.updated_desc + const orderBy = getOrderBy(sort) const list = await db.doc.findMany({ select: { diff --git a/src/lib/api-v1-documents.ts b/src/lib/api-v1-documents.ts index 1472670..40ff259 100644 --- a/src/lib/api-v1-documents.ts +++ b/src/lib/api-v1-documents.ts @@ -8,11 +8,10 @@ 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 -const VALID_SORT_VALUES = ['updated_desc', 'updated_asc', 'created_desc', 'created_asc'] as const -type SortValue = (typeof VALID_SORT_VALUES)[number] const titleSchema = z .string() @@ -123,37 +122,6 @@ function parseListLimit(value: string | null) { return limit } -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 -} - -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 -} - -function getOrderBy(sort: SortValue) { - switch (sort) { - case 'updated_asc': - return [{ updatedAt: 'asc' }, { id: 'asc' }] as const - case 'created_desc': - return [{ createdAt: 'desc' }, { id: 'desc' }] as const - case 'created_asc': - return [{ createdAt: 'asc' }, { id: 'asc' }] as const - case 'updated_desc': - default: - return [{ updatedAt: 'desc' }, { id: 'desc' }] as const - } -} - export function apiDocumentEtag(document: Pick) { const revision = createHash('sha256') .update(`${document.id}:${document.updatedAt.toISOString()}`) @@ -204,26 +172,15 @@ export async function listApiDocuments(userId: string, searchParams: URLSearchPa userId, isDeleted: trash, ...(starred === undefined ? {} : { isStar: starred }), - ...(query - ? { - OR: [ - { title: { contains: query, mode: 'insensitive' } }, - { content: { contains: query, mode: 'insensitive' } }, - ], - } - : {}), - ...(after || before - ? { - updatedAt: { - ...(after ? { gt: after } : {}), - ...(before ? { lt: before } : {}), - }, - } - : {}), + ...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 = [ diff --git a/src/lib/doc-query.ts b/src/lib/doc-query.ts new file mode 100644 index 0000000..7f5842b --- /dev/null +++ b/src/lib/doc-query.ts @@ -0,0 +1,58 @@ +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' }] + } +} From 51f653b4fb9d1199a6012d5fc322912d82c1d144 Mon Sep 17 00:00:00 2001 From: user Date: Fri, 18 Sep 2026 11:19:45 +0800 Subject: [PATCH 3/4] style: fix Prettier formatting for doc-query.ts --- src/lib/doc-query.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/lib/doc-query.ts b/src/lib/doc-query.ts index 7f5842b..509b4d2 100644 --- a/src/lib/doc-query.ts +++ b/src/lib/doc-query.ts @@ -26,10 +26,7 @@ export function parseSort(value: string | null): SortValue { export function buildSearchWhere(query: string): Prisma.DocWhereInput { if (!query) return {} return { - OR: [ - { title: { contains: query, mode: 'insensitive' } }, - { content: { contains: query, mode: 'insensitive' } }, - ], + OR: [{ title: { contains: query, mode: 'insensitive' } }, { content: { contains: query, mode: 'insensitive' } }], } } From f3ed1c35f14405b14dc8013f14766dbee9d9979e Mon Sep 17 00:00:00 2001 From: user Date: Fri, 18 Sep 2026 11:27:33 +0800 Subject: [PATCH 4/4] docs: add CHANGELOG entry for content search and time/sort filters (#68) --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43dc3f5..7b12deb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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