Private
Public Access
1
0

Add 17 features: My Work view, card copy/numbering, calendar, bulk ops, custom fields, automations, webhooks, invites, import/export, and more

Features implemented across 6 batches:
- Batch A: Fix ColorPicker gradient sync, tenant custom logo, unarchive cards, board backgrounds
- Batch B: My Work view, card copy/duplicate, card numbering (BOARD-123)
- Batch C: Due date reminders (cron), card watching with notifications
- Batch D: Bulk card operations, column sorting, calendar view
- Batch E: Invite by link, board import/export (Pounce + Trello JSON)
- Batch F: Webhooks with HMAC signing, custom fields, automation rules engine

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Catherine Renelle
2026-02-27 08:50:15 -05:00
parent 3a11ed01e9
commit 8c41b33313
54 changed files with 3656 additions and 82 deletions
@@ -0,0 +1,58 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { withTenant } from '$lib/server/rls.js';
import { requireBoardEditor, requireBoardView } from '$lib/server/guards.js';
import { canViewBoard } from '$lib/server/permissions.js';
const VALID_TRIGGERS = ['CARD_MOVED', 'DUE_PASSED', 'LABEL_ADDED', 'CARD_CREATED'];
const VALID_ACTIONS = ['ADD_LABEL', 'REMOVE_LABEL', 'ASSIGN', 'SET_DUE', 'MOVE_TO_COLUMN'];
export const GET: RequestHandler = async ({ params, locals }) => {
const tenant = locals.tenant;
if (!tenant) throw error(400, 'Unknown tenant');
const rules = await withTenant(tenant.id, async (tx) => {
const board = await tx.board.findFirst({
where: { id: params.boardId, tenantId: tenant.id },
include: { members: true }
});
if (!board) throw error(404, 'Board not found');
if (!canViewBoard(locals.user, board.visibility, board.members)) {
throw error(403, 'Access denied');
}
return tx.automationRule.findMany({
where: { boardId: params.boardId },
orderBy: { trigger: 'asc' }
});
});
return json({ rules });
};
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 { trigger, triggerConfig, action, actionConfig } = body;
if (!trigger || !VALID_TRIGGERS.includes(trigger)) throw error(400, 'Invalid trigger');
if (!action || !VALID_ACTIONS.includes(action)) throw error(400, 'Invalid action');
const rule = await withTenant(tenant.id, async (tx) => {
await requireBoardEditor(tx, locals.user, params.boardId, tenant.id);
return tx.automationRule.create({
data: {
boardId: params.boardId,
trigger,
triggerConfig: triggerConfig || null,
action,
actionConfig: actionConfig || null
}
});
});
return json({ rule }, { status: 201 });
};
@@ -0,0 +1,50 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { withTenant } from '$lib/server/rls.js';
import { requireBoardEditor } 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 rule = await withTenant(tenant.id, async (tx) => {
await requireBoardEditor(tx, locals.user, params.boardId, tenant.id);
const existing = await tx.automationRule.findFirst({
where: { id: params.ruleId, boardId: params.boardId }
});
if (!existing) throw error(404, 'Rule not found');
const data: Record<string, any> = {};
if (body.active !== undefined) data.active = body.active;
if (body.triggerConfig !== undefined) data.triggerConfig = body.triggerConfig;
if (body.actionConfig !== undefined) data.actionConfig = body.actionConfig;
return tx.automationRule.update({
where: { id: params.ruleId },
data
});
});
return json({ rule });
};
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 requireBoardEditor(tx, locals.user, params.boardId, tenant.id);
const existing = await tx.automationRule.findFirst({
where: { id: params.ruleId, boardId: params.boardId }
});
if (!existing) throw error(404, 'Rule not found');
await tx.automationRule.delete({ where: { id: params.ruleId } });
});
return json({ ok: true });
};
@@ -0,0 +1,140 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { withTenant } from '$lib/server/rls.js';
import { requireBoardEditor } from '$lib/server/guards.js';
import { broadcast } from '$lib/server/realtime.js';
import { logActivity } from '$lib/server/activity.js';
const VALID_ACTIONS = ['move', 'archive', 'delete', 'addLabel', 'assign'] as const;
type BulkAction = (typeof VALID_ACTIONS)[number];
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 { action, cardIds, columnId, tagId, userId } = body as {
action: string;
cardIds: string[];
columnId?: string;
tagId?: string;
userId?: string;
};
if (!action || !VALID_ACTIONS.includes(action as BulkAction)) {
throw error(400, `Invalid action. Must be one of: ${VALID_ACTIONS.join(', ')}`);
}
if (!Array.isArray(cardIds) || cardIds.length === 0) {
throw error(400, 'cardIds must be a non-empty array');
}
if (action === 'move' && !columnId) {
throw error(400, 'columnId is required for move action');
}
if (action === 'addLabel' && !tagId) {
throw error(400, 'tagId is required for addLabel action');
}
if (action === 'assign' && !userId) {
throw error(400, 'userId is required for assign action');
}
const count = await withTenant(tenant.id, async (tx) => {
await requireBoardEditor(tx, locals.user, params.boardId, tenant.id);
// Verify all cards belong to this board
const cards = await tx.card.findMany({
where: {
id: { in: cardIds },
column: { board: { id: params.boardId, tenantId: tenant.id } }
},
select: { id: true, columnId: true }
});
const validCardIds = cards.map((c) => c.id);
if (validCardIds.length === 0) {
throw error(404, 'No matching cards found');
}
let affected = 0;
switch (action as BulkAction) {
case 'move': {
// Verify target column belongs to this board
const column = await tx.column.findFirst({
where: { id: columnId, board: { id: params.boardId, tenantId: tenant.id } }
});
if (!column) throw error(404, 'Target column not found');
const result = await tx.card.updateMany({
where: { id: { in: validCardIds } },
data: { columnId: columnId! }
});
affected = result.count;
break;
}
case 'archive': {
const result = await tx.card.updateMany({
where: { id: { in: validCardIds } },
data: { archived: true }
});
affected = result.count;
break;
}
case 'delete': {
const result = await tx.card.deleteMany({
where: { id: { in: validCardIds } }
});
affected = result.count;
break;
}
case 'addLabel': {
const tag = await tx.tag.findFirst({
where: { id: tagId, tenantId: tenant.id }
});
if (!tag) throw error(404, 'Tag not found');
const result = await tx.cardLabel.createMany({
data: validCardIds.map((cardId) => ({ cardId, tagId: tagId! })),
skipDuplicates: true
});
affected = result.count;
break;
}
case 'assign': {
const member = await tx.boardMember.findFirst({
where: { boardId: params.boardId, userId: userId }
});
if (!member) throw error(400, 'User is not a board member');
const result = await tx.cardAssignee.createMany({
data: validCardIds.map((cardId) => ({ cardId, userId: userId! })),
skipDuplicates: true
});
affected = result.count;
break;
}
}
await logActivity(tx, {
boardId: params.boardId,
userId: locals.user!.id,
action: `bulk.${action}`,
metadata: { cardIds: validCardIds, count: affected }
});
return affected;
});
// Broadcast individual events per card
const socketId = request.headers.get('X-Socket-ID');
const event = action === 'delete' ? 'card:deleted' : 'card:updated';
for (const cardId of cardIds) {
broadcast(params.boardId, event, { cardId, boardId: params.boardId, socketId });
}
broadcast(params.boardId, 'activity:new', { boardId: params.boardId, socketId });
return json({ ok: true, count });
};
@@ -6,6 +6,7 @@ import { withTenant } from '$lib/server/rls.js';
import { requireColumnEditor } from '$lib/server/guards.js';
import { broadcast } from '$lib/server/realtime.js';
import { logActivity } from '$lib/server/activity.js';
import { runAutomations } from '$lib/server/automations.js';
// Create card in column
export const POST: RequestHandler = async ({ params, request, locals }) => {
@@ -29,12 +30,21 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
const position = generatePosition(lastCard?.position, null);
// Assign card number
const board = await tx.board.update({
where: { id: params.boardId },
data: { nextCardNum: { increment: 1 } },
select: { nextCardNum: true }
});
const cardNumber = board.nextCardNum - 1;
const newCard = await tx.card.create({
data: {
columnId: params.columnId,
title: result.data.title,
description: result.data.description,
position
position,
cardNumber
},
include: {
assignees: {
@@ -53,6 +63,12 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
metadata: { title: result.data.title }
});
// Run automations for CARD_CREATED
await runAutomations(tx, params.boardId, 'CARD_CREATED', {
cardId: newCard.id,
cardTitle: newCard.title
});
return newCard;
});
@@ -6,6 +6,8 @@ import { requireBoardView, requireCardEditor } from '$lib/server/guards.js';
import { canViewBoard } from '$lib/server/permissions.js';
import { broadcast } from '$lib/server/realtime.js';
import { logActivity } from '$lib/server/activity.js';
import { notifyWatchers } from '$lib/server/notifications.js';
import { runAutomations } from '$lib/server/automations.js';
// Get single card with full details
export const GET: RequestHandler = async ({ params, locals }) => {
@@ -33,6 +35,7 @@ export const GET: RequestHandler = async ({ params, locals }) => {
include: { user: { select: { id: true, name: true, avatarUrl: true } } }
},
attachments: { orderBy: { createdAt: 'desc' } },
watchers: { select: { userId: true } },
column: {
select: {
id: true,
@@ -103,6 +106,14 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
cardId: params.cardId,
metadata: { from: oldCard?.column.title, to: moved.column.title }
});
// Run automations for CARD_MOVED
await runAutomations(tx, params.boardId, 'CARD_MOVED', {
cardId: params.cardId,
cardTitle: moved.title,
fromColumnId: oldCard?.columnId,
toColumnId: body.columnId
});
}
const { column: _col, ...cardData } = moved;
@@ -116,7 +127,10 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
}
const updateData: Record<string, unknown> = { ...result.data };
if (body.dueDate !== undefined) updateData.dueDate = body.dueDate ? new Date(body.dueDate) : null;
if (body.dueDate !== undefined) {
updateData.dueDate = body.dueDate ? new Date(body.dueDate) : null;
updateData.dueReminderSent = false;
}
if (body.archived !== undefined) updateData.archived = body.archived;
const updated = await tx.card.update({
@@ -149,6 +163,15 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
});
}
// Notify watchers
await notifyWatchers(
tx,
params.cardId,
[locals.user!.id],
`"${updated.title}" was updated`,
`/boards/${params.boardId}?card=${params.cardId}`
);
return updated;
});
@@ -0,0 +1,130 @@
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 { generatePosition } from '$lib/utils/fractional-index.js';
import { broadcast } from '$lib/server/realtime.js';
import { logActivity } from '$lib/server/activity.js';
export const POST: RequestHandler = async ({ params, request, locals }) => {
const tenant = locals.tenant;
if (!tenant) throw error(400, 'Unknown tenant');
const card = await withTenant(tenant.id, async (tx) => {
await requireCardEditor(tx, locals.user, params.cardId, params.boardId, tenant.id);
// Fetch full card data
const original = await tx.card.findFirst({
where: { id: params.cardId },
include: {
labels: true,
assignees: true,
checklists: {
include: { items: { orderBy: { position: 'asc' } } },
orderBy: { position: 'asc' }
}
}
});
if (!original) throw error(404, 'Card not found');
// Position after original
const nextCard = await tx.card.findFirst({
where: {
columnId: original.columnId,
position: { gt: original.position }
},
orderBy: { position: 'asc' }
});
const position = generatePosition(original.position, nextCard?.position ?? null);
// Assign card number
const board = await tx.board.update({
where: { id: params.boardId },
data: { nextCardNum: { increment: 1 } },
select: { nextCardNum: true }
});
const cardNumber = board.nextCardNum - 1;
// Create the copy
const newCard = await tx.card.create({
data: {
columnId: original.columnId,
title: `Copy of ${original.title}`,
description: original.description,
dueDate: original.dueDate,
position,
cardNumber
}
});
// Copy labels
if (original.labels.length > 0) {
await tx.cardLabel.createMany({
data: original.labels.map((l) => ({
cardId: newCard.id,
tagId: l.tagId
}))
});
}
// Copy assignees
if (original.assignees.length > 0) {
await tx.cardAssignee.createMany({
data: original.assignees.map((a) => ({
cardId: newCard.id,
userId: a.userId
}))
});
}
// Copy checklists + items (all unchecked)
for (const cl of original.checklists) {
const newCl = await tx.checklist.create({
data: {
cardId: newCard.id,
title: cl.title,
position: cl.position
}
});
if (cl.items.length > 0) {
await tx.checklistItem.createMany({
data: cl.items.map((item) => ({
checklistId: newCl.id,
title: item.title,
completed: false,
position: item.position
}))
});
}
}
await logActivity(tx, {
boardId: params.boardId,
userId: locals.user!.id,
action: 'card.copied',
cardId: newCard.id,
metadata: { originalId: original.id, title: newCard.title }
});
// Return full card for UI
return tx.card.findFirst({
where: { id: newCard.id },
include: {
assignees: {
include: { user: { select: { id: true, name: true, avatarUrl: true } } }
},
labels: { include: { tag: true } },
checklists: {
select: { id: true, items: { select: { completed: true } } }
},
_count: { select: { comments: true, attachments: true } }
}
});
});
const socketId = request.headers.get('X-Socket-ID');
broadcast(params.boardId, 'card:created', { card, columnId: params.columnId, socketId });
broadcast(params.boardId, 'activity:new', { boardId: params.boardId, socketId });
return json({ card }, { status: 201 });
};
@@ -0,0 +1,49 @@
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 GET: RequestHandler = async ({ params, locals }) => {
const tenant = locals.tenant;
if (!tenant) throw error(400, 'Unknown tenant');
const values = await withTenant(tenant.id, async (tx) => {
return tx.customFieldValue.findMany({
where: { cardId: params.cardId },
include: { field: true }
});
});
return json({ values });
};
export const PUT: RequestHandler = async ({ params, request, locals }) => {
const tenant = locals.tenant;
if (!tenant) throw error(400, 'Unknown tenant');
const body = await request.json();
const { fieldId, value } = body;
if (!fieldId) throw error(400, 'fieldId is required');
const result = await withTenant(tenant.id, async (tx) => {
await requireCardEditor(tx, locals.user, params.cardId, params.boardId, tenant.id);
if (value === null || value === '') {
// Remove value
await tx.customFieldValue.deleteMany({
where: { cardId: params.cardId, fieldId }
});
return null;
}
return tx.customFieldValue.upsert({
where: { cardId_fieldId: { cardId: params.cardId, fieldId } },
create: { cardId: params.cardId, fieldId, value: String(value) },
update: { value: String(value) },
include: { field: true }
});
});
return json({ value: result });
};
@@ -0,0 +1,47 @@
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 { canViewBoard } from '$lib/server/permissions.js';
export const POST: 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 card = await tx.card.findFirst({
where: {
id: params.cardId,
column: { id: params.columnId, board: { id: params.boardId, tenantId: tenant.id } }
},
include: { column: { select: { board: { select: { visibility: true, members: true } } } } }
});
if (!card) throw error(404, 'Card not found');
if (!canViewBoard(locals.user, card.column.board.visibility, card.column.board.members)) {
throw error(403, 'Access denied');
}
await tx.cardWatcher.upsert({
where: { cardId_userId: { cardId: params.cardId, userId: locals.user!.id } },
create: { cardId: params.cardId, userId: locals.user!.id },
update: {}
});
});
return json({ watching: true });
};
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) => {
await tx.cardWatcher.deleteMany({
where: { cardId: params.cardId, userId: locals.user!.id }
});
});
return json({ watching: false });
};
@@ -0,0 +1,64 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { withTenant } from '$lib/server/rls.js';
import { requireBoardEditor, requireBoardView } from '$lib/server/guards.js';
import { canViewBoard } from '$lib/server/permissions.js';
import { generatePosition } from '$lib/utils/fractional-index.js';
export const GET: RequestHandler = async ({ params, locals }) => {
const tenant = locals.tenant;
if (!tenant) throw error(400, 'Unknown tenant');
const fields = await withTenant(tenant.id, async (tx) => {
const board = await tx.board.findFirst({
where: { id: params.boardId, tenantId: tenant.id },
include: { members: true }
});
if (!board) throw error(404, 'Board not found');
if (!canViewBoard(locals.user, board.visibility, board.members)) {
throw error(403, 'Access denied');
}
return tx.customFieldDef.findMany({
where: { boardId: params.boardId },
orderBy: { position: 'asc' }
});
});
return json({ fields });
};
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 { name, type, options } = body;
if (!name || !type) throw error(400, 'Name and type are required');
const validTypes = ['TEXT', 'NUMBER', 'DATE', 'SELECT', 'MULTI_SELECT'];
if (!validTypes.includes(type)) throw error(400, 'Invalid field type');
const field = await withTenant(tenant.id, async (tx) => {
await requireBoardEditor(tx, locals.user, params.boardId, tenant.id);
const lastField = await tx.customFieldDef.findFirst({
where: { boardId: params.boardId },
orderBy: { position: 'desc' }
});
const position = generatePosition(lastField?.position ?? null, null);
return tx.customFieldDef.create({
data: {
boardId: params.boardId,
name,
type,
options: options || null,
position
}
});
});
return json({ field }, { status: 201 });
};
@@ -0,0 +1,50 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { withTenant } from '$lib/server/rls.js';
import { requireBoardEditor } 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 field = await withTenant(tenant.id, async (tx) => {
await requireBoardEditor(tx, locals.user, params.boardId, tenant.id);
const existing = await tx.customFieldDef.findFirst({
where: { id: params.fieldId, boardId: params.boardId }
});
if (!existing) throw error(404, 'Field not found');
const data: Record<string, any> = {};
if (body.name !== undefined) data.name = body.name;
if (body.options !== undefined) data.options = body.options;
if (body.position !== undefined) data.position = body.position;
return tx.customFieldDef.update({
where: { id: params.fieldId },
data
});
});
return json({ field });
};
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 requireBoardEditor(tx, locals.user, params.boardId, tenant.id);
const existing = await tx.customFieldDef.findFirst({
where: { id: params.fieldId, boardId: params.boardId }
});
if (!existing) throw error(404, 'Field not found');
await tx.customFieldDef.delete({ where: { id: params.fieldId } });
});
return json({ ok: true });
};
@@ -0,0 +1,100 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { withTenant } from '$lib/server/rls.js';
import { requireBoardView } from '$lib/server/guards.js';
export const GET: RequestHandler = async ({ params, url, locals }) => {
const tenant = locals.tenant;
if (!tenant) throw error(400, 'Unknown tenant');
const format = url.searchParams.get('format') || 'json';
const board = await withTenant(tenant.id, async (tx) => {
const b = await requireBoardView(tx, locals.user, params.boardId, tenant.id);
// Get full data including checklists
const columns = await tx.column.findMany({
where: { boardId: params.boardId },
orderBy: { position: 'asc' },
include: {
cards: {
orderBy: { position: 'asc' },
include: {
labels: { include: { tag: true } },
assignees: { include: { user: { select: { name: true, email: true } } } },
checklists: {
orderBy: { position: 'asc' },
include: { items: { orderBy: { position: 'asc' } } }
}
}
}
}
});
return {
title: b.title,
description: b.description,
visibility: b.visibility,
columns: columns.map((col) => ({
title: col.title,
cards: col.cards.map((card) => ({
title: card.title,
description: card.description,
dueDate: card.dueDate,
archived: card.archived,
labels: card.labels.map((l) => ({
name: l.tag.name,
color: l.tag.color
})),
assignees: card.assignees.map((a) => ({
name: a.user.name,
email: a.user.email
})),
checklists: card.checklists.map((cl) => ({
title: cl.title,
items: cl.items.map((item) => ({
title: item.title,
completed: item.completed
}))
}))
}))
}))
};
});
if (format === 'csv') {
// Flatten cards into CSV rows
const rows: string[][] = [['Column', 'Title', 'Description', 'Due Date', 'Labels', 'Assignees']];
for (const col of board.columns) {
for (const card of col.cards) {
rows.push([
col.title,
card.title,
card.description || '',
card.dueDate ? new Date(card.dueDate).toISOString().split('T')[0] : '',
card.labels.map((l: any) => l.name).join('; '),
card.assignees.map((a: any) => a.name).join('; ')
]);
}
}
const csv = rows.map((row) =>
row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(',')
).join('\n');
return new Response(csv, {
headers: {
'Content-Type': 'text/csv',
'Content-Disposition': `attachment; filename="${board.title}.csv"`
}
});
}
// JSON export
return new Response(JSON.stringify(board, null, 2), {
headers: {
'Content-Type': 'application/json',
'Content-Disposition': `attachment; filename="${board.title}.json"`
}
});
};