+
+
+
+
-
-
-
Description
- {#if editingDescription && canEdit}
-
-
-
-
-
-
-
- {:else}
-
canEdit && (editingDescription = true)}
- role={canEdit ? 'button' : undefined}
- tabindex={canEdit ? 0 : undefined}
- onkeydown={(e) => { if (canEdit && (e.key === 'Enter' || e.key === ' ')) editingDescription = true; }}
- >
- {#if card.description}
-
{card.description}
- {:else if canEdit}
-
Add a more detailed description...
- {:else}
-
No description
- {/if}
+
+
+
Description
+
+
+
+
+ {#if loadingFull}
+
+ {:else if fullCard}
+
+
+
+
+
{/if}
-
- {#if card.dueDate}
-
-
Due Date
-
{new Date(card.dueDate).toLocaleDateString()}
-
- {/if}
+
+
+
-
- {#if card.assignees && card.assignees.length > 0}
-
-
Assignees
-
- {#each card.assignees as assignee}
-
-
- {assignee.user.name[0].toUpperCase()}
-
-
{assignee.user.name}
-
- {/each}
+
+
+
+ {#if canEdit}
+
+
Actions
+
+
+
+
-
- {/if}
-
-
-
- {#if canEdit}
-
-
+ {/if}
- {/if}
+
diff --git a/src/lib/components/ChecklistSection.svelte b/src/lib/components/ChecklistSection.svelte
new file mode 100644
index 0000000..9550b04
--- /dev/null
+++ b/src/lib/components/ChecklistSection.svelte
@@ -0,0 +1,228 @@
+
+
+
+
Checklists
+
+ {#each checklists as cl}
+ {@const p = progress(cl)}
+
+
+
{cl.title}
+
+
+ {p.done}/{p.total}
+
+ {#if canEdit}
+
+ {/if}
+
+
+
+
+ {#if p.total > 0}
+
+ {/if}
+
+
+
+ {#each cl.items as item}
+
+ {/each}
+
+
+
+ {#if canEdit}
+
+ {/if}
+
+ {/each}
+
+ {#if canEdit}
+ {#if addingChecklist}
+
+ {:else}
+
+ {/if}
+ {/if}
+
diff --git a/src/lib/components/CommentList.svelte b/src/lib/components/CommentList.svelte
new file mode 100644
index 0000000..c026b91
--- /dev/null
+++ b/src/lib/components/CommentList.svelte
@@ -0,0 +1,182 @@
+
+
+
+
Comments
+
+
+ {#if canEdit}
+
+
+
+
+
+
+ {/if}
+
+
+
+ {#each localComments as comment}
+
+
+ {comment.user.name[0].toUpperCase()}
+
+
+
+ {comment.user.name}
+
+ {formatDistanceToNow(new Date(comment.createdAt), { addSuffix: true })}
+
+
+ {#if editingId === comment.id}
+
+
+
+
+
+
+
+ {:else}
+
+ {#if ready}
+ {@html renderMarkdown(comment.content)}
+ {:else}
+
{comment.content}
+ {/if}
+
+
+ {#if comment.userId === currentUserId}
+
+ {/if}
+ {#if comment.userId === currentUserId || canEdit}
+
+ {/if}
+
+ {/if}
+
+
+ {/each}
+
+
+ {#if localComments.length === 0}
+
No comments yet
+ {/if}
+
diff --git a/src/lib/components/DueDatePicker.svelte b/src/lib/components/DueDatePicker.svelte
new file mode 100644
index 0000000..12cc640
--- /dev/null
+++ b/src/lib/components/DueDatePicker.svelte
@@ -0,0 +1,97 @@
+
+
+
+
Due Date
+ {#if picking && canEdit}
+
+ setDate(e.currentTarget.value)}
+ class="rounded border border-gray-300 px-2 py-1 text-sm"
+ disabled={saving}
+ />
+
+
+ {:else if value}
+
+
+ {formatted}
+
+ {#if canEdit}
+
+
+ {/if}
+
+ {:else if canEdit}
+
+ {:else}
+
No due date
+ {/if}
+
diff --git a/src/lib/components/MarkdownEditor.svelte b/src/lib/components/MarkdownEditor.svelte
new file mode 100644
index 0000000..fd6a876
--- /dev/null
+++ b/src/lib/components/MarkdownEditor.svelte
@@ -0,0 +1,108 @@
+
+
+{#if editing && !readonly}
+
+
+
+
+
+ {#if previewing}
+
+ {#if ready}
+ {@html renderMarkdown(draft)}
+ {:else}
+
{draft}
+ {/if}
+
+ {:else}
+
+ {/if}
+
+
+
+
+
+{:else}
+
+
{ if (!readonly) editing = true; }}
+ >
+ {#if value}
+
+ {#if ready}
+ {@html renderMarkdown(value)}
+ {:else}
+
{value}
+ {/if}
+
+ {:else if !readonly}
+
Add a more detailed description...
+ {:else}
+
No description
+ {/if}
+
+{/if}
diff --git a/src/lib/components/TagPicker.svelte b/src/lib/components/TagPicker.svelte
new file mode 100644
index 0000000..2e6555f
--- /dev/null
+++ b/src/lib/components/TagPicker.svelte
@@ -0,0 +1,182 @@
+
+
+
+
Labels
+
+ {#each currentLabels as label}
+
+ {label.tag.name}
+ {#if canEdit}
+
+ {/if}
+
+ {/each}
+ {#if canEdit}
+
+
+ {#if showDropdown}
+
+
(showDropdown = false)}
+ >
+
+ {#if loading}
+
Loading...
+ {:else}
+
+ {#each allTags as tag}
+
+ {/each}
+
+
+
Create tag
+
+ {#each presetColors as color}
+
+ {/each}
+
+
+
+ {/if}
+
+ {/if}
+
+ {/if}
+
+
diff --git a/src/lib/server/guards.ts b/src/lib/server/guards.ts
index 975ff98..2bfc9f8 100644
--- a/src/lib/server/guards.ts
+++ b/src/lib/server/guards.ts
@@ -30,7 +30,10 @@ const boardWithFull = {
include: { user: { select: { id: true, name: true, avatarUrl: true } } }
},
labels: { include: { tag: true } },
- _count: { select: { comments: true, checklists: true, attachments: true } }
+ checklists: {
+ select: { id: true, items: { select: { completed: true } } }
+ },
+ _count: { select: { comments: true, attachments: true } }
}
}
}
diff --git a/src/lib/server/validation.ts b/src/lib/server/validation.ts
index bc7111b..507d5de 100644
--- a/src/lib/server/validation.ts
+++ b/src/lib/server/validation.ts
@@ -14,7 +14,8 @@ export const loginSchema = z.object({
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')
+ visibility: z.enum(['PUBLIC', 'PRIVATE']).default('PRIVATE'),
+ categoryId: z.string().uuid().optional()
});
export const columnSchema = z.object({
@@ -47,3 +48,36 @@ export const changeMemberRoleSchema = z.object({
export const changeTenantRoleSchema = z.object({
tenantRole: z.enum(['ADMIN', 'MEMBER'])
});
+
+export const tagSchema = z.object({
+ name: z.string().min(1, 'Name is required').max(50),
+ color: z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Invalid hex color')
+});
+
+export const categorySchema = z.object({
+ name: z.string().min(1, 'Name is required').max(100)
+});
+
+export const cardLabelSchema = z.object({
+ tagId: z.string().uuid()
+});
+
+export const cardAssigneeSchema = z.object({
+ userId: z.string().uuid()
+});
+
+export const checklistSchema = z.object({
+ title: z.string().min(1, 'Title is required').max(200)
+});
+
+export const checklistItemSchema = z.object({
+ title: z.string().min(1, 'Title is required').max(500)
+});
+
+export const checklistItemToggleSchema = z.object({
+ completed: z.boolean()
+});
+
+export const commentSchema = z.object({
+ content: z.string().min(1, 'Content is required').max(10000)
+});
diff --git a/src/lib/utils/due-date.ts b/src/lib/utils/due-date.ts
new file mode 100644
index 0000000..3c9d1d5
--- /dev/null
+++ b/src/lib/utils/due-date.ts
@@ -0,0 +1,30 @@
+import { isPast, differenceInHours, formatDistanceToNow } from 'date-fns';
+
+export type DueUrgency = 'overdue' | 'due-soon' | 'upcoming' | 'none';
+
+export function getDueUrgency(dueDate: Date | string | null | undefined): DueUrgency {
+ if (!dueDate) return 'none';
+ const d = typeof dueDate === 'string' ? new Date(dueDate) : dueDate;
+ if (isPast(d)) return 'overdue';
+ if (differenceInHours(d, new Date()) <= 24) return 'due-soon';
+ return 'upcoming';
+}
+
+export function getUrgencyClasses(urgency: DueUrgency): string {
+ switch (urgency) {
+ case 'overdue':
+ return 'bg-red-100 text-red-700';
+ case 'due-soon':
+ return 'bg-yellow-100 text-yellow-700';
+ case 'upcoming':
+ return 'bg-green-100 text-green-700';
+ default:
+ return 'bg-gray-100 text-gray-500';
+ }
+}
+
+export function formatDueDate(dueDate: Date | string | null | undefined): string {
+ if (!dueDate) return '';
+ const d = typeof dueDate === 'string' ? new Date(dueDate) : dueDate;
+ return formatDistanceToNow(d, { addSuffix: true });
+}
diff --git a/src/lib/utils/markdown.ts b/src/lib/utils/markdown.ts
new file mode 100644
index 0000000..74b032a
--- /dev/null
+++ b/src/lib/utils/markdown.ts
@@ -0,0 +1,22 @@
+import { marked } from 'marked';
+
+export function renderMarkdown(text: string): string {
+ if (!text) return '';
+ const html = marked.parse(text, { async: false }) as string;
+ if (typeof window === 'undefined') return html;
+ const DOMPurify = (globalThis as any).__dompurify;
+ if (DOMPurify) return DOMPurify.sanitize(html);
+ return html;
+}
+
+let _initPromise: Promise
| null = null;
+
+export function initDOMPurify(): Promise {
+ if (typeof window === 'undefined') return Promise.resolve();
+ if ((globalThis as any).__dompurify) return Promise.resolve();
+ if (_initPromise) return _initPromise;
+ _initPromise = import('dompurify').then((mod) => {
+ (globalThis as any).__dompurify = mod.default;
+ });
+ return _initPromise;
+}
diff --git a/src/routes/api/attachments/[attachmentId]/+server.ts b/src/routes/api/attachments/[attachmentId]/+server.ts
new file mode 100644
index 0000000..07d028a
--- /dev/null
+++ b/src/routes/api/attachments/[attachmentId]/+server.ts
@@ -0,0 +1,90 @@
+import { 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 { canViewBoard } from '$lib/server/permissions.js';
+import { readFile, unlink } from 'fs/promises';
+import { json } from '@sveltejs/kit';
+
+export const GET: RequestHandler = async ({ params, locals }) => {
+ const tenant = locals.tenant;
+ if (!tenant) throw error(400, 'Unknown tenant');
+
+ const attachment = await withTenant(tenant.id, async (tx) => {
+ const att = await tx.attachment.findFirst({
+ where: { id: params.attachmentId },
+ include: {
+ card: {
+ include: {
+ column: {
+ include: {
+ board: {
+ select: { tenantId: true, visibility: true, members: true }
+ }
+ }
+ }
+ }
+ }
+ }
+ });
+ if (!att) throw error(404, 'Attachment not found');
+ if (att.card.column.board.tenantId !== tenant.id) throw error(404, 'Attachment not found');
+
+ if (!canViewBoard(locals.user, att.card.column.board.visibility, att.card.column.board.members)) {
+ if (!locals.user) throw error(401, 'Not authenticated');
+ throw error(403, 'Access denied');
+ }
+
+ return att;
+ });
+
+ const buffer = await readFile(attachment.filepath);
+ return new Response(buffer, {
+ headers: {
+ 'Content-Type': attachment.mimetype,
+ 'Content-Disposition': `attachment; filename="${attachment.filename}"`,
+ 'Content-Length': String(attachment.size)
+ }
+ });
+};
+
+export const DELETE: RequestHandler = async ({ params, locals }) => {
+ const tenant = locals.tenant;
+ if (!tenant) throw error(400, 'Unknown tenant');
+ requireAuth(locals.user);
+
+ await withTenant(tenant.id, async (tx) => {
+ const att = await tx.attachment.findFirst({
+ where: { id: params.attachmentId },
+ include: {
+ card: {
+ include: {
+ column: {
+ include: {
+ board: { select: { tenantId: true, members: true } }
+ }
+ }
+ }
+ }
+ }
+ });
+ if (!att) throw error(404, 'Attachment not found');
+ if (att.card.column.board.tenantId !== tenant.id) throw error(404, 'Attachment not found');
+
+ // Check board editor permission
+ const { canEditBoard } = await import('$lib/server/permissions.js');
+ if (!canEditBoard(locals.user, att.card.column.board.members)) {
+ throw error(403, 'Access denied');
+ }
+
+ await tx.attachment.delete({ where: { id: params.attachmentId } });
+
+ try {
+ await unlink(att.filepath);
+ } catch {
+ // File may already be gone
+ }
+ });
+
+ return json({ ok: true });
+};
diff --git a/src/routes/api/boards/+server.ts b/src/routes/api/boards/+server.ts
index 697bc42..b22a574 100644
--- a/src/routes/api/boards/+server.ts
+++ b/src/routes/api/boards/+server.ts
@@ -50,7 +50,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
return json({ error: result.error.issues[0].message }, { status: 400 });
}
- const { title, description, visibility } = result.data;
+ const { title, description, visibility, categoryId } = result.data;
const board = await withTenant(tenant.id, async (tx) => {
return tx.board.create({
@@ -59,6 +59,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
title,
description,
visibility,
+ ...(categoryId ? { categoryId } : {}),
members: {
create: {
userId: locals.user!.id,
diff --git a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/assignees/+server.ts b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/assignees/+server.ts
new file mode 100644
index 0000000..397ac34
--- /dev/null
+++ b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/assignees/+server.ts
@@ -0,0 +1,53 @@
+import { json, error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types.js';
+import { cardAssigneeSchema } from '$lib/server/validation.js';
+import { withTenant } from '$lib/server/rls.js';
+import { requireCardEditor } from '$lib/server/guards.js';
+
+export const POST: RequestHandler = async ({ params, request, locals }) => {
+ const tenant = locals.tenant;
+ if (!tenant) throw error(400, 'Unknown tenant');
+
+ const body = await request.json();
+ const result = cardAssigneeSchema.safeParse(body);
+ if (!result.success) throw error(400, result.error.issues[0].message);
+
+ const assignee = await withTenant(tenant.id, async (tx) => {
+ const card = await requireCardEditor(tx, locals.user, params.cardId, params.boardId, tenant.id);
+
+ // Verify user is a board member
+ const member = await tx.boardMember.findFirst({
+ where: { boardId: params.boardId, userId: result.data.userId }
+ });
+ if (!member) throw error(400, 'User is not a board member');
+
+ return tx.cardAssignee.create({
+ data: { cardId: params.cardId, userId: result.data.userId },
+ include: { user: { select: { id: true, name: true, email: true, avatarUrl: true } } }
+ });
+ });
+
+ return json({ assignee }, { status: 201 });
+};
+
+export const DELETE: RequestHandler = async ({ params, request, locals }) => {
+ const tenant = locals.tenant;
+ if (!tenant) throw error(400, 'Unknown tenant');
+
+ const body = await request.json();
+ const result = cardAssigneeSchema.safeParse(body);
+ if (!result.success) throw error(400, result.error.issues[0].message);
+
+ await withTenant(tenant.id, async (tx) => {
+ await requireCardEditor(tx, locals.user, params.cardId, params.boardId, tenant.id);
+
+ const assignee = await tx.cardAssignee.findFirst({
+ where: { cardId: params.cardId, userId: result.data.userId }
+ });
+ if (!assignee) throw error(404, 'Assignee not found');
+
+ await tx.cardAssignee.delete({ where: { id: assignee.id } });
+ });
+
+ return json({ ok: true });
+};
diff --git a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/attachments/+server.ts b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/attachments/+server.ts
new file mode 100644
index 0000000..0b1c560
--- /dev/null
+++ b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/attachments/+server.ts
@@ -0,0 +1,46 @@
+import { json, error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types.js';
+import { withTenant } from '$lib/server/rls.js';
+import { requireCardEditor } from '$lib/server/guards.js';
+import { writeFile, mkdir } from 'fs/promises';
+import { join } from 'path';
+import { randomUUID } from 'crypto';
+
+const MAX_SIZE = 10 * 1024 * 1024; // 10MB
+const UPLOAD_ROOT = '/app/uploads';
+
+export const POST: RequestHandler = async ({ params, request, locals }) => {
+ const tenant = locals.tenant;
+ if (!tenant) throw error(400, 'Unknown tenant');
+
+ const formData = await request.formData();
+ const file = formData.get('file') as File | null;
+ if (!file) throw error(400, 'No file provided');
+ if (file.size > MAX_SIZE) throw error(400, 'File too large (max 10MB)');
+
+ const attachment = await withTenant(tenant.id, async (tx) => {
+ await requireCardEditor(tx, locals.user, params.cardId, params.boardId, tenant.id);
+
+ const dir = join(UPLOAD_ROOT, tenant.id, params.cardId);
+ await mkdir(dir, { recursive: true });
+
+ const ext = file.name.includes('.') ? '.' + file.name.split('.').pop() : '';
+ const storedName = randomUUID() + ext;
+ const filepath = join(dir, storedName);
+
+ const buffer = Buffer.from(await file.arrayBuffer());
+ await writeFile(filepath, buffer);
+
+ return tx.attachment.create({
+ data: {
+ cardId: params.cardId,
+ filename: file.name,
+ filepath,
+ mimetype: file.type || 'application/octet-stream',
+ size: file.size
+ }
+ });
+ });
+
+ return json({ attachment }, { status: 201 });
+};
diff --git a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/checklists/+server.ts b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/checklists/+server.ts
new file mode 100644
index 0000000..d582002
--- /dev/null
+++ b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/checklists/+server.ts
@@ -0,0 +1,37 @@
+import { json, error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types.js';
+import { checklistSchema } from '$lib/server/validation.js';
+import { withTenant } from '$lib/server/rls.js';
+import { requireCardEditor } from '$lib/server/guards.js';
+import { generatePosition } from '$lib/utils/fractional-index.js';
+
+export const POST: RequestHandler = async ({ params, request, locals }) => {
+ const tenant = locals.tenant;
+ if (!tenant) throw error(400, 'Unknown tenant');
+
+ const body = await request.json();
+ const result = checklistSchema.safeParse(body);
+ if (!result.success) throw error(400, result.error.issues[0].message);
+
+ const checklist = await withTenant(tenant.id, async (tx) => {
+ await requireCardEditor(tx, locals.user, params.cardId, params.boardId, tenant.id);
+
+ const last = await tx.checklist.findFirst({
+ where: { cardId: params.cardId },
+ orderBy: { position: 'desc' }
+ });
+
+ const position = generatePosition(last?.position, null);
+
+ return tx.checklist.create({
+ data: {
+ cardId: params.cardId,
+ title: result.data.title,
+ position
+ },
+ include: { items: true }
+ });
+ });
+
+ return json({ checklist }, { status: 201 });
+};
diff --git a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/checklists/[checklistId]/+server.ts b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/checklists/[checklistId]/+server.ts
new file mode 100644
index 0000000..44cde83
--- /dev/null
+++ b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/checklists/[checklistId]/+server.ts
@@ -0,0 +1,49 @@
+import { json, error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types.js';
+import { checklistSchema } from '$lib/server/validation.js';
+import { withTenant } from '$lib/server/rls.js';
+import { requireCardEditor } from '$lib/server/guards.js';
+
+export const PATCH: RequestHandler = async ({ params, request, locals }) => {
+ const tenant = locals.tenant;
+ if (!tenant) throw error(400, 'Unknown tenant');
+
+ const body = await request.json();
+ const result = checklistSchema.partial().safeParse(body);
+ if (!result.success) throw error(400, result.error.issues[0].message);
+
+ const checklist = await withTenant(tenant.id, async (tx) => {
+ await requireCardEditor(tx, locals.user, params.cardId, params.boardId, tenant.id);
+
+ const existing = await tx.checklist.findFirst({
+ where: { id: params.checklistId, cardId: params.cardId }
+ });
+ if (!existing) throw error(404, 'Checklist not found');
+
+ return tx.checklist.update({
+ where: { id: params.checklistId },
+ data: result.data,
+ include: { items: { orderBy: { position: 'asc' } } }
+ });
+ });
+
+ return json({ checklist });
+};
+
+export const DELETE: RequestHandler = async ({ params, locals }) => {
+ const tenant = locals.tenant;
+ if (!tenant) throw error(400, 'Unknown tenant');
+
+ await withTenant(tenant.id, async (tx) => {
+ await requireCardEditor(tx, locals.user, params.cardId, params.boardId, tenant.id);
+
+ const existing = await tx.checklist.findFirst({
+ where: { id: params.checklistId, cardId: params.cardId }
+ });
+ if (!existing) throw error(404, 'Checklist not found');
+
+ await tx.checklist.delete({ where: { id: params.checklistId } });
+ });
+
+ return json({ ok: true });
+};
diff --git a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/checklists/[checklistId]/items/+server.ts b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/checklists/[checklistId]/items/+server.ts
new file mode 100644
index 0000000..cb7e878
--- /dev/null
+++ b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/checklists/[checklistId]/items/+server.ts
@@ -0,0 +1,41 @@
+import { json, error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types.js';
+import { checklistItemSchema } from '$lib/server/validation.js';
+import { withTenant } from '$lib/server/rls.js';
+import { requireCardEditor } from '$lib/server/guards.js';
+import { generatePosition } from '$lib/utils/fractional-index.js';
+
+export const POST: RequestHandler = async ({ params, request, locals }) => {
+ const tenant = locals.tenant;
+ if (!tenant) throw error(400, 'Unknown tenant');
+
+ const body = await request.json();
+ const result = checklistItemSchema.safeParse(body);
+ if (!result.success) throw error(400, result.error.issues[0].message);
+
+ const item = await withTenant(tenant.id, async (tx) => {
+ await requireCardEditor(tx, locals.user, params.cardId, params.boardId, tenant.id);
+
+ const checklist = await tx.checklist.findFirst({
+ where: { id: params.checklistId, cardId: params.cardId }
+ });
+ if (!checklist) throw error(404, 'Checklist not found');
+
+ const last = await tx.checklistItem.findFirst({
+ where: { checklistId: params.checklistId },
+ orderBy: { position: 'desc' }
+ });
+
+ const position = generatePosition(last?.position, null);
+
+ return tx.checklistItem.create({
+ data: {
+ checklistId: params.checklistId,
+ title: result.data.title,
+ position
+ }
+ });
+ });
+
+ return json({ item }, { status: 201 });
+};
diff --git a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/checklists/[checklistId]/items/[itemId]/+server.ts b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/checklists/[checklistId]/items/[itemId]/+server.ts
new file mode 100644
index 0000000..7013c37
--- /dev/null
+++ b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/checklists/[checklistId]/items/[itemId]/+server.ts
@@ -0,0 +1,56 @@
+import { json, error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types.js';
+import { withTenant } from '$lib/server/rls.js';
+import { requireCardEditor } from '$lib/server/guards.js';
+
+export const PATCH: RequestHandler = async ({ params, request, locals }) => {
+ const tenant = locals.tenant;
+ if (!tenant) throw error(400, 'Unknown tenant');
+
+ const body = await request.json();
+
+ const item = await withTenant(tenant.id, async (tx) => {
+ await requireCardEditor(tx, locals.user, params.cardId, params.boardId, tenant.id);
+
+ const existing = await tx.checklistItem.findFirst({
+ where: {
+ id: params.itemId,
+ checklist: { id: params.checklistId, cardId: params.cardId }
+ }
+ });
+ if (!existing) throw error(404, 'Checklist item not found');
+
+ const data: Record = {};
+ if (typeof body.title === 'string') data.title = body.title;
+ if (typeof body.completed === 'boolean') data.completed = body.completed;
+ if (typeof body.position === 'string') data.position = body.position;
+
+ return tx.checklistItem.update({
+ where: { id: params.itemId },
+ data
+ });
+ });
+
+ return json({ item });
+};
+
+export const DELETE: RequestHandler = async ({ params, locals }) => {
+ const tenant = locals.tenant;
+ if (!tenant) throw error(400, 'Unknown tenant');
+
+ await withTenant(tenant.id, async (tx) => {
+ await requireCardEditor(tx, locals.user, params.cardId, params.boardId, tenant.id);
+
+ const existing = await tx.checklistItem.findFirst({
+ where: {
+ id: params.itemId,
+ checklist: { id: params.checklistId, cardId: params.cardId }
+ }
+ });
+ if (!existing) throw error(404, 'Checklist item not found');
+
+ await tx.checklistItem.delete({ where: { id: params.itemId } });
+ });
+
+ return json({ ok: true });
+};
diff --git a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/comments/+server.ts b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/comments/+server.ts
new file mode 100644
index 0000000..1afdf05
--- /dev/null
+++ b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/comments/+server.ts
@@ -0,0 +1,31 @@
+import { json, error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types.js';
+import { commentSchema } from '$lib/server/validation.js';
+import { withTenant } from '$lib/server/rls.js';
+import { requireCardEditor } from '$lib/server/guards.js';
+
+export const POST: RequestHandler = async ({ params, request, locals }) => {
+ const tenant = locals.tenant;
+ if (!tenant) throw error(400, 'Unknown tenant');
+
+ const body = await request.json();
+ const result = commentSchema.safeParse(body);
+ if (!result.success) throw error(400, result.error.issues[0].message);
+
+ const comment = await withTenant(tenant.id, async (tx) => {
+ await requireCardEditor(tx, locals.user, params.cardId, params.boardId, tenant.id);
+
+ return tx.comment.create({
+ data: {
+ cardId: params.cardId,
+ userId: locals.user!.id,
+ content: result.data.content
+ },
+ include: {
+ user: { select: { id: true, name: true, avatarUrl: true } }
+ }
+ });
+ });
+
+ return json({ comment }, { status: 201 });
+};
diff --git a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/comments/[commentId]/+server.ts b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/comments/[commentId]/+server.ts
new file mode 100644
index 0000000..1d65972
--- /dev/null
+++ b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/comments/[commentId]/+server.ts
@@ -0,0 +1,64 @@
+import { json, error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types.js';
+import { commentSchema } from '$lib/server/validation.js';
+import { withTenant } from '$lib/server/rls.js';
+import { requireCardEditor, requireAuth } from '$lib/server/guards.js';
+
+export const PATCH: RequestHandler = async ({ params, request, locals }) => {
+ const tenant = locals.tenant;
+ if (!tenant) throw error(400, 'Unknown tenant');
+ requireAuth(locals.user);
+
+ const body = await request.json();
+ const result = commentSchema.safeParse(body);
+ if (!result.success) throw error(400, result.error.issues[0].message);
+
+ const comment = await withTenant(tenant.id, async (tx) => {
+ const existing = await tx.comment.findFirst({
+ where: {
+ id: params.commentId,
+ cardId: params.cardId,
+ card: { column: { board: { id: params.boardId, tenantId: tenant.id } } }
+ }
+ });
+ if (!existing) throw error(404, 'Comment not found');
+ if (existing.userId !== locals.user!.id) throw error(403, 'Can only edit own comments');
+
+ return tx.comment.update({
+ where: { id: params.commentId },
+ data: { content: result.data.content },
+ include: {
+ user: { select: { id: true, name: true, avatarUrl: true } }
+ }
+ });
+ });
+
+ return json({ comment });
+};
+
+export const DELETE: RequestHandler = async ({ params, locals }) => {
+ const tenant = locals.tenant;
+ if (!tenant) throw error(400, 'Unknown tenant');
+
+ await withTenant(tenant.id, async (tx) => {
+ const existing = await tx.comment.findFirst({
+ where: {
+ id: params.commentId,
+ cardId: params.cardId,
+ card: { column: { board: { id: params.boardId, tenantId: tenant.id } } }
+ }
+ });
+ if (!existing) throw error(404, 'Comment not found');
+
+ // Own comment — always allowed. Otherwise need board editor.
+ if (existing.userId !== locals.user?.id) {
+ await requireCardEditor(tx, locals.user, params.cardId, params.boardId, tenant.id);
+ } else {
+ requireAuth(locals.user);
+ }
+
+ await tx.comment.delete({ where: { id: params.commentId } });
+ });
+
+ return json({ ok: true });
+};
diff --git a/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/labels/+server.ts b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/labels/+server.ts
new file mode 100644
index 0000000..3cd581b
--- /dev/null
+++ b/src/routes/api/boards/[boardId]/columns/[columnId]/cards/[cardId]/labels/+server.ts
@@ -0,0 +1,52 @@
+import { json, error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types.js';
+import { cardLabelSchema } from '$lib/server/validation.js';
+import { withTenant } from '$lib/server/rls.js';
+import { requireCardEditor } from '$lib/server/guards.js';
+
+export const POST: RequestHandler = async ({ params, request, locals }) => {
+ const tenant = locals.tenant;
+ if (!tenant) throw error(400, 'Unknown tenant');
+
+ const body = await request.json();
+ const result = cardLabelSchema.safeParse(body);
+ if (!result.success) throw error(400, result.error.issues[0].message);
+
+ const label = await withTenant(tenant.id, async (tx) => {
+ await requireCardEditor(tx, locals.user, params.cardId, params.boardId, tenant.id);
+
+ const tag = await tx.tag.findFirst({
+ where: { id: result.data.tagId, tenantId: tenant.id }
+ });
+ if (!tag) throw error(404, 'Tag not found');
+
+ return tx.cardLabel.create({
+ data: { cardId: params.cardId, tagId: result.data.tagId },
+ include: { tag: true }
+ });
+ });
+
+ return json({ label }, { status: 201 });
+};
+
+export const DELETE: RequestHandler = async ({ params, request, locals }) => {
+ const tenant = locals.tenant;
+ if (!tenant) throw error(400, 'Unknown tenant');
+
+ const body = await request.json();
+ const result = cardLabelSchema.safeParse(body);
+ if (!result.success) throw error(400, result.error.issues[0].message);
+
+ await withTenant(tenant.id, async (tx) => {
+ await requireCardEditor(tx, locals.user, params.cardId, params.boardId, tenant.id);
+
+ const label = await tx.cardLabel.findFirst({
+ where: { cardId: params.cardId, tagId: result.data.tagId }
+ });
+ if (!label) throw error(404, 'Label not found');
+
+ await tx.cardLabel.delete({ where: { id: label.id } });
+ });
+
+ return json({ ok: true });
+};
diff --git a/src/routes/api/categories/+server.ts b/src/routes/api/categories/+server.ts
new file mode 100644
index 0000000..eef4935
--- /dev/null
+++ b/src/routes/api/categories/+server.ts
@@ -0,0 +1,38 @@
+import { json, error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types.js';
+import { categorySchema } from '$lib/server/validation.js';
+import { withTenant } from '$lib/server/rls.js';
+import { requireAuth } from '$lib/server/guards.js';
+
+export const GET: RequestHandler = async ({ locals }) => {
+ const tenant = locals.tenant;
+ if (!tenant) throw error(400, 'Unknown tenant');
+ requireAuth(locals.user);
+
+ const categories = await withTenant(tenant.id, async (tx) => {
+ return tx.category.findMany({
+ where: { tenantId: tenant.id },
+ orderBy: { name: 'asc' }
+ });
+ });
+
+ return json({ categories });
+};
+
+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 result = categorySchema.safeParse(body);
+ if (!result.success) throw error(400, result.error.issues[0].message);
+
+ const category = await withTenant(tenant.id, async (tx) => {
+ return tx.category.create({
+ data: { tenantId: tenant.id, name: result.data.name }
+ });
+ });
+
+ return json({ category }, { status: 201 });
+};
diff --git a/src/routes/api/categories/[categoryId]/+server.ts b/src/routes/api/categories/[categoryId]/+server.ts
new file mode 100644
index 0000000..a656a6d
--- /dev/null
+++ b/src/routes/api/categories/[categoryId]/+server.ts
@@ -0,0 +1,46 @@
+import { json, error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types.js';
+import { categorySchema } from '$lib/server/validation.js';
+import { withTenant } from '$lib/server/rls.js';
+import { requireAuth } from '$lib/server/guards.js';
+
+export const PATCH: RequestHandler = async ({ params, request, locals }) => {
+ const tenant = locals.tenant;
+ if (!tenant) throw error(400, 'Unknown tenant');
+ requireAuth(locals.user);
+
+ const body = await request.json();
+ const result = categorySchema.partial().safeParse(body);
+ if (!result.success) throw error(400, result.error.issues[0].message);
+
+ const category = await withTenant(tenant.id, async (tx) => {
+ const existing = await tx.category.findFirst({
+ where: { id: params.categoryId, tenantId: tenant.id }
+ });
+ if (!existing) throw error(404, 'Category not found');
+
+ return tx.category.update({
+ where: { id: params.categoryId },
+ data: result.data
+ });
+ });
+
+ return json({ category });
+};
+
+export const DELETE: RequestHandler = async ({ params, locals }) => {
+ const tenant = locals.tenant;
+ if (!tenant) throw error(400, 'Unknown tenant');
+ requireAuth(locals.user);
+
+ await withTenant(tenant.id, async (tx) => {
+ const existing = await tx.category.findFirst({
+ where: { id: params.categoryId, tenantId: tenant.id }
+ });
+ if (!existing) throw error(404, 'Category not found');
+
+ await tx.category.delete({ where: { id: params.categoryId } });
+ });
+
+ return json({ ok: true });
+};
diff --git a/src/routes/api/tags/+server.ts b/src/routes/api/tags/+server.ts
new file mode 100644
index 0000000..aba9c3e
--- /dev/null
+++ b/src/routes/api/tags/+server.ts
@@ -0,0 +1,38 @@
+import { json, error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types.js';
+import { tagSchema } from '$lib/server/validation.js';
+import { withTenant } from '$lib/server/rls.js';
+import { requireAuth } from '$lib/server/guards.js';
+
+export const GET: RequestHandler = async ({ locals }) => {
+ const tenant = locals.tenant;
+ if (!tenant) throw error(400, 'Unknown tenant');
+ requireAuth(locals.user);
+
+ const tags = await withTenant(tenant.id, async (tx) => {
+ return tx.tag.findMany({
+ where: { tenantId: tenant.id },
+ orderBy: { name: 'asc' }
+ });
+ });
+
+ return json({ tags });
+};
+
+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 result = tagSchema.safeParse(body);
+ if (!result.success) throw error(400, result.error.issues[0].message);
+
+ const tag = await withTenant(tenant.id, async (tx) => {
+ return tx.tag.create({
+ data: { tenantId: tenant.id, name: result.data.name, color: result.data.color }
+ });
+ });
+
+ return json({ tag }, { status: 201 });
+};
diff --git a/src/routes/api/tags/[tagId]/+server.ts b/src/routes/api/tags/[tagId]/+server.ts
new file mode 100644
index 0000000..67b581f
--- /dev/null
+++ b/src/routes/api/tags/[tagId]/+server.ts
@@ -0,0 +1,46 @@
+import { json, error } from '@sveltejs/kit';
+import type { RequestHandler } from './$types.js';
+import { tagSchema } from '$lib/server/validation.js';
+import { withTenant } from '$lib/server/rls.js';
+import { requireAuth } from '$lib/server/guards.js';
+
+export const PATCH: RequestHandler = async ({ params, request, locals }) => {
+ const tenant = locals.tenant;
+ if (!tenant) throw error(400, 'Unknown tenant');
+ requireAuth(locals.user);
+
+ const body = await request.json();
+ const result = tagSchema.partial().safeParse(body);
+ if (!result.success) throw error(400, result.error.issues[0].message);
+
+ const tag = await withTenant(tenant.id, async (tx) => {
+ const existing = await tx.tag.findFirst({
+ where: { id: params.tagId, tenantId: tenant.id }
+ });
+ if (!existing) throw error(404, 'Tag not found');
+
+ return tx.tag.update({
+ where: { id: params.tagId },
+ data: result.data
+ });
+ });
+
+ return json({ tag });
+};
+
+export const DELETE: RequestHandler = async ({ params, locals }) => {
+ const tenant = locals.tenant;
+ if (!tenant) throw error(400, 'Unknown tenant');
+ requireAuth(locals.user);
+
+ await withTenant(tenant.id, async (tx) => {
+ const existing = await tx.tag.findFirst({
+ where: { id: params.tagId, tenantId: tenant.id }
+ });
+ if (!existing) throw error(404, 'Tag not found');
+
+ await tx.tag.delete({ where: { id: params.tagId } });
+ });
+
+ return json({ ok: true });
+};
diff --git a/src/routes/boards/+page.server.ts b/src/routes/boards/+page.server.ts
index 6031db5..b1bf1a2 100644
--- a/src/routes/boards/+page.server.ts
+++ b/src/routes/boards/+page.server.ts
@@ -3,9 +3,9 @@ import { withTenant } from '$lib/server/rls.js';
export const load: PageServerLoad = async ({ locals }) => {
const tenant = locals.tenant;
- if (!tenant) return { boards: [] };
+ if (!tenant) return { boards: [], categories: [] };
- const boards = await withTenant(tenant.id, async (tx) => {
+ const result = await withTenant(tenant.id, async (tx) => {
const where: any = { tenantId: tenant.id, archived: false };
if (!locals.user) {
@@ -17,17 +17,28 @@ export const load: PageServerLoad = async ({ locals }) => {
];
}
- return tx.board.findMany({
- where,
- include: {
- members: {
- include: { user: { select: { id: true, name: true, avatarUrl: true } } }
+ const [boards, categories] = await Promise.all([
+ tx.board.findMany({
+ where,
+ include: {
+ members: {
+ include: { user: { select: { id: true, name: true, avatarUrl: true } } }
+ },
+ category: true,
+ _count: { select: { columns: true } }
},
- _count: { select: { columns: true } }
- },
- orderBy: { updatedAt: 'desc' }
- });
+ orderBy: { updatedAt: 'desc' }
+ }),
+ locals.user
+ ? tx.category.findMany({
+ where: { tenantId: tenant.id },
+ orderBy: { name: 'asc' }
+ })
+ : []
+ ]);
+
+ return { boards, categories };
});
- return { boards };
+ return result;
};
diff --git a/src/routes/boards/+page.svelte b/src/routes/boards/+page.svelte
index 85d42f8..a9f8c25 100644
--- a/src/routes/boards/+page.svelte
+++ b/src/routes/boards/+page.svelte
@@ -6,8 +6,16 @@
let showCreate = $state(false);
let newTitle = $state('');
let newVisibility = $state<'PRIVATE' | 'PUBLIC'>('PRIVATE');
+ let newCategoryId = $state('');
let creating = $state(false);
let error = $state('');
+ let filterCategoryId = $state('');
+
+ const filteredBoards = $derived(
+ filterCategoryId
+ ? data.boards.filter((b: any) => b.categoryId === filterCategoryId)
+ : data.boards
+ );
async function createBoard(e: Event) {
e.preventDefault();
@@ -19,7 +27,11 @@
const res = await fetch('/api/boards', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ title: newTitle, visibility: newVisibility })
+ body: JSON.stringify({
+ title: newTitle,
+ visibility: newVisibility,
+ ...(newCategoryId ? { categoryId: newCategoryId } : {})
+ })
});
const result = await res.json();
if (!res.ok) {
@@ -27,6 +39,7 @@
return;
}
newTitle = '';
+ newCategoryId = '';
showCreate = false;
invalidateAll();
} catch {
@@ -59,8 +72,8 @@
{#if error}
{error}
{/if}
-