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 @@