Private
Public Access
1
0

Phase 4: Rich card features — labels, checklists, comments, attachments, categories

Add full CRUD + UI for all card sub-resources (tags/labels, assignees, checklists
with items, comments with markdown, file attachments). Restructure CardModal into
a two-column layout with lazy-loaded data. Upgrade card thumbnails with named label
pills, urgency-colored due date badges, checklist progress indicators, and SVG icons.
Add board categories with filtering on the listing page. Include markdown rendering
with DOMPurify sanitization and .prose CSS styles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Catherine Renelle
2026-02-25 23:38:31 -05:00
parent 9b5708af64
commit 095d9756a8
33 changed files with 2226 additions and 143 deletions
+2 -1
View File
@@ -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,
@@ -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 });
};
@@ -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 });
};
@@ -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 });
};
@@ -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 });
};
@@ -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 });
};
@@ -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<string, unknown> = {};
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 });
};
@@ -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 });
};
@@ -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 });
};
@@ -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 });
};