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