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:
@@ -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,
|
||||
|
||||
+53
@@ -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 });
|
||||
};
|
||||
+46
@@ -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 });
|
||||
};
|
||||
+37
@@ -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 });
|
||||
};
|
||||
+49
@@ -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 });
|
||||
};
|
||||
+41
@@ -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 });
|
||||
};
|
||||
+56
@@ -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 });
|
||||
};
|
||||
+64
@@ -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 });
|
||||
};
|
||||
Reference in New Issue
Block a user