From 6ed03495074a1361eb16bec2a2f544cc529ecf83 Mon Sep 17 00:00:00 2001 From: Catherine Renelle <32781029+catrenelle@users.noreply.github.com> Date: Thu, 26 Feb 2026 00:42:20 -0500 Subject: [PATCH] Phase 7: Search, filters, templates, and archive UI Add full-text card search (PostgreSQL tsvector + GIN index) with navbar SearchBar, client-side board filters (label/assignee/due date), board templates (3 system templates + save-as-template API), and archive toggle for both board listing and board view. Co-Authored-By: Claude Opus 4.6 --- docker-entrypoint.sh | 7 + prisma/seed.ts | 53 ++++++ scripts/setup-search.sql | 9 + src/lib/components/BoardFilters.svelte | 167 ++++++++++++++++++ src/lib/components/SearchBar.svelte | 95 ++++++++++ src/lib/server/validation.ts | 3 +- src/routes/+layout.svelte | 5 + src/routes/api/boards/+server.ts | 38 +++- .../[boardId]/archived-cards/+server.ts | 31 ++++ src/routes/api/search/+server.ts | 43 +++++ src/routes/api/templates/+server.ts | 72 ++++++++ src/routes/boards/+page.server.ts | 32 +++- src/routes/boards/+page.svelte | 82 +++++++-- src/routes/boards/[boardId]/+page.svelte | 117 +++++++++++- 14 files changed, 727 insertions(+), 27 deletions(-) create mode 100644 scripts/setup-search.sql create mode 100644 src/lib/components/BoardFilters.svelte create mode 100644 src/lib/components/SearchBar.svelte create mode 100644 src/routes/api/boards/[boardId]/archived-cards/+server.ts create mode 100644 src/routes/api/search/+server.ts create mode 100644 src/routes/api/templates/+server.ts diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 90ac540..d21e437 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -27,5 +27,12 @@ else echo "WARNING: Failed to apply RLS policies. Continuing anyway..." fi +echo "Applying full-text search indexes..." +if psql "$DATABASE_URL" -f ./scripts/setup-search.sql; then + echo "Search indexes applied successfully." +else + echo "WARNING: Failed to apply search indexes. Continuing anyway..." +fi + echo "Starting server..." exec node server.js diff --git a/prisma/seed.ts b/prisma/seed.ts index 3fd6f7e..c6a0bcb 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -36,6 +36,59 @@ async function main() { } }); + // Seed system templates (tenantId: null) + const systemTemplates = [ + { + name: 'Basic Kanban', + description: 'Simple three-column workflow', + template: { columns: [{ title: 'To Do' }, { title: 'In Progress' }, { title: 'Done' }] } + }, + { + name: 'Scrum Sprint', + description: 'Sprint-based agile workflow', + template: { + columns: [ + { title: 'Backlog' }, + { title: 'Sprint' }, + { title: 'In Review' }, + { title: 'Done' } + ] + } + }, + { + name: 'Bug Tracker', + description: 'Issue lifecycle tracking', + template: { + columns: [ + { title: 'New' }, + { title: 'Confirmed' }, + { title: 'In Progress' }, + { title: 'Resolved' }, + { title: 'Closed' } + ] + } + } + ]; + + for (const tpl of systemTemplates) { + const existing = await prisma.boardTemplate.findFirst({ + where: { tenantId: null, name: tpl.name } + }); + if (!existing) { + await prisma.boardTemplate.create({ + data: { + tenantId: null, + name: tpl.name, + description: tpl.description, + template: tpl.template + } + }); + console.log(` Created system template: ${tpl.name}`); + } else { + console.log(` System template exists: ${tpl.name}`); + } + } + console.log('Seed complete.'); console.log(' Admin: admin@example.com / admin123'); } diff --git a/scripts/setup-search.sql b/scripts/setup-search.sql new file mode 100644 index 0000000..88b1d4a --- /dev/null +++ b/scripts/setup-search.sql @@ -0,0 +1,9 @@ +-- Full-text search on cards +-- Generated tsvector column + GIN index for fast searching + +ALTER TABLE cards ADD COLUMN IF NOT EXISTS search_vector tsvector + GENERATED ALWAYS AS ( + to_tsvector('english', coalesce(title, '') || ' ' || coalesce(description, '')) + ) STORED; + +CREATE INDEX IF NOT EXISTS cards_search_idx ON cards USING gin(search_vector); diff --git a/src/lib/components/BoardFilters.svelte b/src/lib/components/BoardFilters.svelte new file mode 100644 index 0000000..990d326 --- /dev/null +++ b/src/lib/components/BoardFilters.svelte @@ -0,0 +1,167 @@ + + +
+ + + {#if hasFilters} + + {/if} + + {#if expanded} + + {#if allTags().length > 0} +
+ Labels: + {#each allTags() as tag} + + {/each} +
+ {/if} + + + {#if members.length > 0} +
+ Assignee: + {#each members as member} + + {/each} +
+ {/if} + + +
+ Due: + {#each [{ val: 'overdue', label: 'Overdue' }, { val: 'due-soon', label: 'Due soon' }, { val: 'no-date', label: 'No date' }] as opt} + + {/each} +
+ {/if} +
diff --git a/src/lib/components/SearchBar.svelte b/src/lib/components/SearchBar.svelte new file mode 100644 index 0000000..a116e50 --- /dev/null +++ b/src/lib/components/SearchBar.svelte @@ -0,0 +1,95 @@ + + + + +
+
+ + + + debounceSearch(query)} + onkeydown={handleKeydown} + onfocus={() => { if (results.length > 0) open = true; }} + class="w-56 rounded-lg bg-white/20 pl-8 pr-3 py-1.5 text-sm text-white placeholder-white/50 focus:bg-white/30 focus:outline-none focus:ring-1 focus:ring-white/40 transition" + /> + {#if loading} +
+
+
+ {/if} +
+ + {#if open} +
+ {#each results as result} + + {/each} +
+ {/if} +
diff --git a/src/lib/server/validation.ts b/src/lib/server/validation.ts index 507d5de..442b312 100644 --- a/src/lib/server/validation.ts +++ b/src/lib/server/validation.ts @@ -15,7 +15,8 @@ export const boardSchema = z.object({ title: z.string().min(1, 'Title is required').max(200), description: z.string().max(2000).optional(), visibility: z.enum(['PUBLIC', 'PRIVATE']).default('PRIVATE'), - categoryId: z.string().uuid().optional() + categoryId: z.string().uuid().optional(), + templateId: z.string().uuid().optional() }); export const columnSchema = z.object({ diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index b04479d..407f86f 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -4,6 +4,7 @@ import { onMount } from 'svelte'; import { connectSocket, disconnectSocket } from '$lib/stores/socket.js'; import NotificationBell from '$lib/components/NotificationBell.svelte'; + import SearchBar from '$lib/components/SearchBar.svelte'; let { children, data }: { children: Snippet; data: { user: any; tenant: any } } = $props(); @@ -29,6 +30,10 @@ {data.tenant?.name ?? 'Pounce'} + {#if data.user} + + {/if} +
{#if data.user} {#if data.user.tenantRole === 'ADMIN' || data.user.globalRole === 'SITE_ADMIN'} diff --git a/src/routes/api/boards/+server.ts b/src/routes/api/boards/+server.ts index b22a574..33ca21e 100644 --- a/src/routes/api/boards/+server.ts +++ b/src/routes/api/boards/+server.ts @@ -3,6 +3,8 @@ import type { RequestHandler } from './$types.js'; import { boardSchema } from '$lib/server/validation.js'; import { withTenant } from '$lib/server/rls.js'; import { requireAuth } from '$lib/server/guards.js'; +import { prisma } from '$lib/server/db.js'; +import { generatePositions } from '$lib/utils/fractional-index.js'; // List boards for current tenant export const GET: RequestHandler = async ({ locals }) => { @@ -50,10 +52,26 @@ export const POST: RequestHandler = async ({ request, locals }) => { return json({ error: result.error.issues[0].message }, { status: 400 }); } - const { title, description, visibility, categoryId } = result.data; + const { title, description, visibility, categoryId, templateId } = result.data; + + // Load template columns if templateId provided + let templateColumns: { title: string }[] = []; + if (templateId) { + // System templates have null tenantId, so query without RLS + const template = await prisma.boardTemplate.findFirst({ + where: { + id: templateId, + OR: [{ tenantId: null }, { tenantId: tenant.id }] + } + }); + if (template) { + const tpl = template.template as { columns?: { title: string }[] }; + templateColumns = tpl.columns || []; + } + } const board = await withTenant(tenant.id, async (tx) => { - return tx.board.create({ + const newBoard = await tx.board.create({ data: { tenantId: tenant.id, title, @@ -73,6 +91,22 @@ export const POST: RequestHandler = async ({ request, locals }) => { } } }); + + // Create columns from template + if (templateColumns.length > 0) { + const positions = generatePositions(templateColumns.length); + for (let i = 0; i < templateColumns.length; i++) { + await tx.column.create({ + data: { + boardId: newBoard.id, + title: templateColumns[i].title, + position: positions[i] + } + }); + } + } + + return newBoard; }); return json({ board }, { status: 201 }); diff --git a/src/routes/api/boards/[boardId]/archived-cards/+server.ts b/src/routes/api/boards/[boardId]/archived-cards/+server.ts new file mode 100644 index 0000000..2626b10 --- /dev/null +++ b/src/routes/api/boards/[boardId]/archived-cards/+server.ts @@ -0,0 +1,31 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types.js'; +import { withTenant } from '$lib/server/rls.js'; +import { requireAuth, requireBoardView } from '$lib/server/guards.js'; + +export const GET: RequestHandler = async ({ params, locals }) => { + const tenant = locals.tenant; + if (!tenant) throw error(400, 'Unknown tenant'); + requireAuth(locals.user); + + const cards = await withTenant(tenant.id, async (tx) => { + // Verify user has access to this board + await requireBoardView(tx, locals.user, params.boardId, tenant.id); + + return tx.card.findMany({ + where: { + archived: true, + column: { board: { id: params.boardId, tenantId: tenant.id } } + }, + include: { + labels: { include: { tag: true } }, + assignees: { + include: { user: { select: { id: true, name: true, avatarUrl: true } } } + } + }, + orderBy: { updatedAt: 'desc' } + }); + }); + + return json({ cards }); +}; diff --git a/src/routes/api/search/+server.ts b/src/routes/api/search/+server.ts new file mode 100644 index 0000000..e81c0ad --- /dev/null +++ b/src/routes/api/search/+server.ts @@ -0,0 +1,43 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types.js'; +import { withTenant } from '$lib/server/rls.js'; +import { requireAuth } from '$lib/server/guards.js'; +import { prisma } from '$lib/server/db.js'; + +export const GET: RequestHandler = async ({ url, locals }) => { + const tenant = locals.tenant; + if (!tenant) throw error(400, 'Unknown tenant'); + requireAuth(locals.user); + + const q = url.searchParams.get('q')?.trim(); + if (!q) return json({ results: [] }); + + const limit = Math.min(parseInt(url.searchParams.get('limit') || '10'), 50); + + const results = await withTenant(tenant.id, async (tx) => { + const cards: any[] = await tx.$queryRaw` + SELECT + c.id, + c.title, + c.description, + c.column_id AS "columnId", + col.title AS "columnTitle", + b.id AS "boardId", + b.title AS "boardTitle" + FROM cards c + JOIN columns col ON col.id = c.column_id + JOIN boards b ON b.id = col.board_id + LEFT JOIN board_members bm ON bm.board_id = b.id AND bm.user_id = ${locals.user!.id}::uuid + WHERE c.search_vector @@ plainto_tsquery('english', ${q}) + AND b.tenant_id = ${tenant.id}::uuid + AND c.archived = false + AND b.archived = false + AND (b.visibility = 'PUBLIC' OR bm.user_id IS NOT NULL) + ORDER BY ts_rank(c.search_vector, plainto_tsquery('english', ${q})) DESC + LIMIT ${limit} + `; + return cards; + }); + + return json({ results }); +}; diff --git a/src/routes/api/templates/+server.ts b/src/routes/api/templates/+server.ts new file mode 100644 index 0000000..ee39864 --- /dev/null +++ b/src/routes/api/templates/+server.ts @@ -0,0 +1,72 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types.js'; +import { withTenant } from '$lib/server/rls.js'; +import { requireAuth } from '$lib/server/guards.js'; +import { prisma } from '$lib/server/db.js'; + +// List system + tenant templates +export const GET: RequestHandler = async ({ locals }) => { + const tenant = locals.tenant; + if (!tenant) throw error(400, 'Unknown tenant'); + requireAuth(locals.user); + + // System templates bypass RLS (tenantId is null) + // Tenant templates go through RLS + const [systemTemplates, tenantTemplates] = await Promise.all([ + prisma.boardTemplate.findMany({ + where: { tenantId: null }, + orderBy: { name: 'asc' } + }), + withTenant(tenant.id, async (tx) => { + return tx.boardTemplate.findMany({ + where: { tenantId: tenant.id }, + orderBy: { name: 'asc' } + }); + }) + ]); + + return json({ templates: [...systemTemplates, ...tenantTemplates] }); +}; + +// Save a board as a tenant template +export const POST: RequestHandler = async ({ request, locals }) => { + const tenant = locals.tenant; + if (!tenant) throw error(400, 'Unknown tenant'); + requireAuth(locals.user); + + const body = await request.json(); + const { name, description, boardId } = body; + + if (!name || typeof name !== 'string' || name.trim().length === 0) { + return json({ error: 'Name is required' }, { status: 400 }); + } + if (!boardId || typeof boardId !== 'string') { + return json({ error: 'Board ID is required' }, { status: 400 }); + } + + const template = await withTenant(tenant.id, async (tx) => { + // Fetch the board's columns + const board = await tx.board.findFirst({ + where: { id: boardId, tenantId: tenant.id }, + include: { + columns: { orderBy: { position: 'asc' }, select: { title: true } } + } + }); + if (!board) throw error(404, 'Board not found'); + + const templateJson = { + columns: board.columns.map((c) => ({ title: c.title })) + }; + + return tx.boardTemplate.create({ + data: { + tenantId: tenant.id, + name: name.trim(), + description: description?.trim() || null, + template: templateJson + } + }); + }); + + return json({ template }, { status: 201 }); +}; diff --git a/src/routes/boards/+page.server.ts b/src/routes/boards/+page.server.ts index b1bf1a2..318e630 100644 --- a/src/routes/boards/+page.server.ts +++ b/src/routes/boards/+page.server.ts @@ -1,12 +1,18 @@ import type { PageServerLoad } from './$types.js'; import { withTenant } from '$lib/server/rls.js'; +import { prisma } from '$lib/server/db.js'; -export const load: PageServerLoad = async ({ locals }) => { +export const load: PageServerLoad = async ({ locals, url }) => { const tenant = locals.tenant; - if (!tenant) return { boards: [], categories: [] }; + if (!tenant) return { boards: [], categories: [], templates: [] }; + + const showArchived = url.searchParams.get('archived') === 'true'; const result = await withTenant(tenant.id, async (tx) => { - const where: any = { tenantId: tenant.id, archived: false }; + const where: any = { tenantId: tenant.id }; + if (!showArchived) { + where.archived = false; + } if (!locals.user) { where.visibility = 'PUBLIC'; @@ -40,5 +46,23 @@ export const load: PageServerLoad = async ({ locals }) => { return { boards, categories }; }); - return result; + // Fetch templates (system + tenant) — only if logged in + let templates: any[] = []; + if (locals.user) { + const [systemTemplates, tenantTemplates] = await Promise.all([ + prisma.boardTemplate.findMany({ + where: { tenantId: null }, + orderBy: { name: 'asc' } + }), + withTenant(tenant.id, async (tx) => { + return tx.boardTemplate.findMany({ + where: { tenantId: tenant.id }, + orderBy: { name: 'asc' } + }); + }) + ]); + templates = [...systemTemplates, ...tenantTemplates]; + } + + return { ...result, templates }; }; diff --git a/src/routes/boards/+page.svelte b/src/routes/boards/+page.svelte index a9f8c25..b72c030 100644 --- a/src/routes/boards/+page.svelte +++ b/src/routes/boards/+page.svelte @@ -1,5 +1,7 @@