diff --git a/app/components/board/BoardSearchToolbar.vue b/app/components/board/BoardSearchToolbar.vue index c9a1fa4d..e4687744 100644 --- a/app/components/board/BoardSearchToolbar.vue +++ b/app/components/board/BoardSearchToolbar.vue @@ -2,6 +2,7 @@ import { useMediaQuery } from '@vueuse/core' const q = defineModel({ default: '' }) const sortBy = defineModel<'top' | 'recent'>('sort', { default: 'recent' }) +const statusFilter = defineModel('status', { default: 'active' }) const emit = defineEmits<{ 'new-request': [] }>() const searchOpen = ref(false) @@ -98,6 +99,7 @@ defineExpose({ reset })
+
+ @@ -513,6 +572,15 @@ nav::-webkit-scrollbar { display: none; } +.fl-empty__link { + font-size: 14px; + margin-top: 8px; + font-weight: 500; + color: var(--primary); + text-decoration: underline; + text-underline-offset: 3px; +} + .fl-empty__hint { font-size: 14px; margin: 4px 0 20px; diff --git a/i18n/locales/en.json b/i18n/locales/en.json index c9f72530..592bf2b5 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -61,12 +61,17 @@ "recentRequests": "Recent Requests", "sortTop": "Top", "sortRecent": "Recent", + "statusFilter": "Filter by status", + "statusActive": "Active", + "statusAll": "All statuses", "search": "Search", "searchPlaceholder": "Search feedback…", "clearSearch": "Clear search", "closeSearch": "Close search", "newRequest": "New Request", "noFeedback": "No feedback yet", + "noFeedbackFiltered": "Nothing matches this status", + "showAllStatuses": "Show all statuses", "noMatches": "No matches for “{query}”", "noMatchesHint": "Nobody’s raised this yet — be the first.", "comments": "{n} comments", diff --git a/i18n/locales/zh.json b/i18n/locales/zh.json index 47cd921d..8e45bfb3 100644 --- a/i18n/locales/zh.json +++ b/i18n/locales/zh.json @@ -61,12 +61,17 @@ "recentRequests": "最新请求", "sortTop": "热门", "sortRecent": "最新", + "statusFilter": "按状态筛选", + "statusActive": "待解决", + "statusAll": "全部状态", "search": "搜索", "searchPlaceholder": "搜索反馈…", "clearSearch": "清除搜索", "closeSearch": "关闭搜索", "newRequest": "新建反馈", "noFeedback": "还没有反馈", + "noFeedbackFiltered": "没有符合该状态的反馈", + "showAllStatuses": "显示全部状态", "noMatches": "没有匹配“{query}”的结果", "noMatchesHint": "还没有人提过 —— 来做第一个吧。", "comments": "{n} 条评论", diff --git a/server/api/boards/index.get.ts b/server/api/boards/index.get.ts index ad7acf37..99018ad9 100644 --- a/server/api/boards/index.get.ts +++ b/server/api/boards/index.get.ts @@ -1,3 +1,7 @@ import { boardOverview } from '../../services/boards' +import { parseStatusParam } from '#layers/feedlog/shared/types/post' -export default defineEventHandler(event => boardOverview({ db: useDB(), orgId: event.context.orgId! })) +export default defineEventHandler(event => boardOverview( + { db: useDB(), orgId: event.context.orgId! }, + parseStatusParam(getQuery(event).status), +)) diff --git a/server/api/posts/index.get.ts b/server/api/posts/index.get.ts index 89ef0ff7..4688f0ef 100644 --- a/server/api/posts/index.get.ts +++ b/server/api/posts/index.get.ts @@ -1,11 +1,14 @@ import { listPublicFeedback } from '#layers/feedlog/server/services/feedback-web-queries' +import { parseStatusParam } from '#layers/feedlog/shared/types/post' export default defineEventHandler(async (event): Promise> => { const session = await getUserSession(event) const query = getQuery(event) const boardId = query.boardId as string | undefined - const status = query.status as string | undefined + // `status` takes one name or a comma-separated list, so the board can ask for + // "everything still live" in one round trip. Unknown names are dropped. + const statuses = parseStatusParam(query.status) const sort = (query.sort as string) || 'createdAt' const order = (query.order as string) || 'desc' const cursor = query.cursor as string | undefined @@ -13,5 +16,5 @@ export default defineEventHandler(async (event): Promise { const session = await getUserSession(event) const query = getQuery(event) const orgId = event.context.orgId! + const filter = { orgId, status: parseStatusParam(query.status), merged: 'canonical_only' as const } const q = ((query.q as string | undefined) ?? '').trim() if (!q) return { data: [] as PostListItem[] } @@ -29,7 +32,7 @@ export default defineEventHandler(async (event) => { let rows if (embedding) { rows = await searchPostsBySemantic(embedding, { - filter: { orgId, merged: 'canonical_only' }, + filter, limit: PUBLIC_SEARCH_LIMIT, }) } @@ -57,7 +60,7 @@ export default defineEventHandler(async (event) => { .from(post) .innerJoin(postSearch, eq(post.id, postSearch.postId)) .leftJoin(user, eq(post.authorId, user.id)) - .where(and(eq(post.orgId, orgId), isNull(post.mergedTo))) + .where(and(...postFilterConditions(filter))) .orderBy(sql`${postSearch.searchText} <-> ${q}`) .limit(PUBLIC_SEARCH_LIMIT) } diff --git a/server/services/boards.ts b/server/services/boards.ts index a295290e..f702fcc5 100644 --- a/server/services/boards.ts +++ b/server/services/boards.ts @@ -38,19 +38,19 @@ export const boardFields = { } // Both dashboard counts and paginated board summaries exclude merged children. const boardFeedbackCount = sql`count(${post.id})::int` -export async function listBoards(context: ContentReader, input?: CursorPageInput) { - const scope = { kind: 'boards', organizationId: context.orgId, filter: {}, sort: 'position', ascending: true } +export async function listBoards(context: ContentReader, input?: CursorPageInput, statuses?: string[]) { + const scope = { kind: 'boards', organizationId: context.orgId, filter: statuses?.length ? { status: statuses } : {}, sort: 'position', ascending: true } const page = input ? await cursorPage(input, scope, board.position, board.id, true, true) : undefined const query = context.db.select({ ...boardFields, feedbackCount: boardFeedbackCount, cursorKey: board.position }).from(board) - .leftJoin(post, and(eq(post.boardId, board.id), ...postFilterConditions({ orgId: context.orgId }))) + .leftJoin(post, and(eq(post.boardId, board.id), ...postFilterConditions({ orgId: context.orgId, status: statuses }))) .where(and(eq(board.orgId, context.orgId), page?.after)).groupBy(board.id).orderBy(asc(board.position), asc(board.id)) const rows = await (input ? query.limit(input.limit + 1) : query) return page ? page.finish(rows) : { items: rows.map(({ cursorKey: _key, ...row }) => row), nextCursor: null } } -export async function boardOverview(context: ContentReader) { +export async function boardOverview(context: ContentReader, statuses?: string[]) { const [page, totals] = await Promise.all([ - listBoards(context), - context.db.select({ count: sql`count(*)::int` }).from(post).where(and(...postFilterConditions({ orgId: context.orgId }))), + listBoards(context, undefined, statuses), + context.db.select({ count: sql`count(*)::int` }).from(post).where(and(...postFilterConditions({ orgId: context.orgId, status: statuses }))), ]) return { data: page.items.map(({ id, name, description, position, feedbackCount, createdAt }) => ({ id, name, description, position, postCount: feedbackCount, createdAt })), diff --git a/server/services/feedback-web-queries.ts b/server/services/feedback-web-queries.ts index 05fd93ae..3d484959 100644 --- a/server/services/feedback-web-queries.ts +++ b/server/services/feedback-web-queries.ts @@ -8,11 +8,11 @@ import { z } from 'zod' import { encodeWebCursor, decodeWebCursor } from './web-pagination' import { createError } from 'h3' -export async function listPublicFeedback(context: ContentReader, input: { boardId?: string; status?: string; sort: string; order: string; cursor?: string; pageSize: number }, viewerId?: string) { +export async function listPublicFeedback(context: ContentReader, input: { boardId?: string; status?: string[]; sort: string; order: string; cursor?: string; pageSize: number }, viewerId?: string) { const { db, orgId } = context const { boardId, status, sort, order, cursor, pageSize } = input // Default: exclude merged posts - const conditions = postFilterConditions({ orgId, boardId: boardId ? [boardId] : undefined, status: status ? [status] : undefined }) + const conditions = postFilterConditions({ orgId, boardId: boardId ? [boardId] : undefined, status }) // Cursor conditions if (cursor) { diff --git a/shared/types/post.ts b/shared/types/post.ts index eb89d167..05bab5f3 100644 --- a/shared/types/post.ts +++ b/shared/types/post.ts @@ -2,6 +2,34 @@ export const POST_STATUSES = ['open', 'planned', 'in_progress', 'done'] as const export type PostStatus = typeof POST_STATUSES[number] +// The board's default view: work that is still live. 'done' is the only status +// a visitor has to ask for explicitly, because a finished card answers nothing +// they came to the board for. +export const ACTIVE_STATUSES = ['open', 'planned', 'in_progress'] as const + +/** What the board's status control can be set to. */ +export type BoardStatusFilter = 'active' | 'all' | PostStatus + +/** Statuses a filter selection admits, or null for "no status condition". */ +export function statusFilterToStatuses(filter: BoardStatusFilter): PostStatus[] | null { + if (filter === 'all') return null + if (filter === 'active') return [...ACTIVE_STATUSES] + return [filter] +} + +/** + * Parse a `status` query value: one status name, or a comma-separated list of + * them. Unknown names are dropped rather than rejected — a hand-written link + * with a typo should still return a board, not a 400. + */ +export function parseStatusParam(raw: unknown): PostStatus[] { + if (typeof raw !== 'string') return [] + return raw + .split(',') + .map(s => s.trim()) + .filter((s): s is PostStatus => (POST_STATUSES as readonly string[]).includes(s)) +} + // Roadmap only shows these three statuses (excludes 'open') export const ROADMAP_STATUSES = ['planned', 'in_progress', 'done'] as const export type RoadmapStatus = typeof ROADMAP_STATUSES[number] diff --git a/tests/board-status.integration.test.ts b/tests/board-status.integration.test.ts new file mode 100644 index 00000000..ad014824 --- /dev/null +++ b/tests/board-status.integration.test.ts @@ -0,0 +1,116 @@ +import assert from 'node:assert/strict' +import { randomUUID } from 'node:crypto' +import { createServer } from 'node:http' +import { test } from 'node:test' +import { Pool } from 'pg' + +const target = process.env.BOARD_TEST_URL + +test('public board status filters apply before pagination, counts and search limits', { skip: !target, timeout: 120_000 }, async (t) => { + const origin = new URL(target!).origin + const database = new URL(process.env.BOARD_TEST_DATABASE_URL ?? process.env.DATABASE_URL!) + assert.ok(['localhost', '127.0.0.1'].includes(new URL(origin).hostname)) + assert.ok(['localhost', '127.0.0.1'].includes(database.hostname)) + assert.match(database.pathname, /test|review/, 'Use an isolated test database') + const port = Number(process.env.BOARD_TEST_EMBEDDING_PORT) + assert.ok(Number.isSafeInteger(port) && port > 0, 'Configure the application to use this local embedding stub') + const pool = new Pool({ connectionString: database.href }) + const orgId = process.env.BOARD_TEST_ORG_ID ?? 'default-org' + const prefix = `board-test-${randomUUID()}` + const authorId = prefix + const otherOrg = `${prefix}-other` + const boardId = randomUUID() + const emptyBoardId = randomUUID() + const feedbackIds: string[] = [] + const vector = (x: number, y = 0) => [x, y, ...Array(766).fill(0)] + let semantic = true + let embeddingRequests = 0 + const embedding = createServer(async (request, response) => { + for await (const _chunk of request) { /* Drain the request before replying. */ } + embeddingRequests++ + response.setHeader('content-type', 'application/json') + response.writeHead(semantic ? 200 : 503) + response.end(JSON.stringify(semantic ? { data: [{ embedding: vector(1) }] } : { error: 'Test fallback' })) + }) + await new Promise(resolve => embedding.listen(port, '127.0.0.1', resolve)) + async function get(path: string) { + const response = await fetch(origin + path, { signal: AbortSignal.timeout(20_000) }) + assert.equal(response.status, 200, await response.clone().text()) + return response.json() + } + async function insert(status: string, title: string, options: { org?: string; board?: string; mergedTo?: string; embedding?: number[] } = {}) { + const id = randomUUID() + await pool.query('INSERT INTO post (id,org_id,title,slug,content,status,board_id,author_id,merged_to) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)', + [id, options.org ?? orgId, title, id, title, status, options.board ?? boardId, authorId, options.mergedTo ?? null]) + feedbackIds.push(id) + await pool.query('INSERT INTO post_search (post_id,org_id,search_text) VALUES ($1,$2,$3)', [id, options.org ?? orgId, title]) + if (options.embedding) await pool.query('INSERT INTO post_embedding (post_id,org_id,embedding,model,content_hash) VALUES ($1,$2,$3,$4,$5)', + [id, options.org ?? orgId, JSON.stringify(options.embedding), 'test', id]) + return id + } + try { + await pool.query('INSERT INTO "user" (id,name,email) VALUES ($1,$1,$2)', [authorId, `${prefix}@example.invalid`]) + await pool.query('INSERT INTO organization (id,name,slug) VALUES ($1,$1,$1)', [otherOrg]) + for (const id of [boardId, emptyBoardId]) { + await pool.query('INSERT INTO board (id,org_id,name,position) VALUES ($1,$2,$3,1000)', [id, orgId, id === boardId ? prefix : `${prefix}-empty`]) + } + const live: string[] = [] + for (const status of ['open', 'planned', 'in_progress', 'done']) { + const id = await insert(status, `${prefix} ${status}`) + if (status !== 'done') live.push(id) + } + const needle = `${prefix} search regression` + for (let i = 0; i < 30; i++) await insert('done', needle, { embedding: vector(1) }) + const wanted = await insert('open', `${needle} pending change`, { embedding: vector(0.8, 0.2) }) + live.push(wanted) + const merged = await insert('open', needle, { mergedTo: wanted, embedding: vector(1) }) + const foreign = await insert('open', needle, { org: otherOrg, embedding: vector(1) }) + + await t.test('list pagination and sidebar counts share status and merged-post rules', async () => { + const seen: string[] = [] + let cursor: string | undefined + do { + const params = new URLSearchParams({ boardId, status: 'open,planned,in_progress', pageSize: '2' }) + if (cursor) params.set('cursor', cursor) + const page = await get(`/api/posts?${params}`) + seen.push(...page.data.map((post: { id: string }) => post.id)) + cursor = page.pagination.nextCursor + } while (cursor) + assert.deepEqual(seen.sort(), live.sort()) + const overview = await get('/api/boards?status=open,planned,in_progress') + assert.equal(overview.data.find((board: { id: string }) => board.id === boardId).postCount, live.length) + assert.equal(overview.data.find((board: { id: string }) => board.id === emptyBoardId).postCount, 0) + const all = await get('/api/boards') + assert.equal(all.data.find((board: { id: string }) => board.id === boardId).postCount, 35) + const invalid = await get('/api/boards?status=invalid') + assert.equal(invalid.data.find((board: { id: string }) => board.id === boardId).postCount, 35) + }) + + for (const mode of ['semantic', 'trigram']) { + await t.test(`${mode} search filters before the top-30 limit and preserves tenant boundaries`, async () => { + semantic = mode === 'semantic' + const before = embeddingRequests + const query = new URLSearchParams({ q: needle, status: 'open,planned,in_progress' }) + const result = await get(`/api/posts/search?${query}`) + assert.ok(result.data.some((post: { id: string }) => post.id === wanted)) + assert.ok(result.data.every((post: { status: string }) => post.status !== 'done')) + assert.ok(result.data.every((post: { id: string }) => post.id !== merged && post.id !== foreign)) + assert.ok(embeddingRequests > before, 'Exercise the configured embedding path before fallback') + for (const status of ['done', 'invalid', '']) { + const search = new URLSearchParams({ q: needle }) + if (status) search.set('status', status) + const page = await get(`/api/posts/search?${search}`) + assert.equal(page.data.length, 30) + assert.ok(page.data.every((post: { status: string }) => post.status === 'done')) + } + }) + } + } finally { + await pool.query('DELETE FROM post WHERE id=ANY($1::uuid[])', [feedbackIds]) + await pool.query('DELETE FROM board WHERE id=ANY($1::uuid[])', [[boardId, emptyBoardId]]) + await pool.query('DELETE FROM organization WHERE id=$1', [otherOrg]) + await pool.query('DELETE FROM "user" WHERE id=$1', [authorId]) + await pool.end() + await new Promise((resolve, reject) => embedding.close(error => error ? reject(error) : resolve())) + } +})