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
@@ -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 });
};