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
+201
View File
@@ -0,0 +1,201 @@
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 { generatePositions } from '$lib/utils/fractional-index.js';
const MAX_CARDS = 500;
interface ImportColumn {
title: string;
cards: ImportCard[];
}
interface ImportCard {
title: string;
description?: string;
dueDate?: string | null;
labels?: { name: string; color: string }[];
checklists?: {
title: string;
items: { title: string; completed?: boolean }[];
}[];
}
export const POST: RequestHandler = async ({ request, locals }) => {
const tenant = locals.tenant;
if (!tenant) throw error(400, 'Unknown tenant');
requireAuth(locals.user);
const body = await request.json();
const { data: importData, format } = body;
if (!importData) throw error(400, 'No import data provided');
let columns: ImportColumn[];
let boardTitle: string;
if (format === 'trello') {
// Trello JSON import
boardTitle = importData.name || 'Imported Board';
const listsById = new Map<string, string>();
columns = (importData.lists || []).map((list: any) => {
listsById.set(list.id, list.name);
return { title: list.name, cards: [] };
});
const trelloCards = importData.cards || [];
if (trelloCards.length > MAX_CARDS) {
throw error(400, `Too many cards (max ${MAX_CARDS})`);
}
for (const tc of trelloCards) {
if (tc.closed) continue;
const colIdx = columns.findIndex((c: ImportColumn) => c.title === listsById.get(tc.idList));
if (colIdx === -1) continue;
const card: ImportCard = {
title: tc.name,
description: tc.desc || undefined,
dueDate: tc.due || null,
labels: (tc.labels || []).map((l: any) => ({
name: l.name || l.color,
color: l.color ? `#${l.color}` : '#6b7280'
})),
checklists: []
};
// Map Trello checklists
if (importData.checklists) {
const cardChecklists = importData.checklists.filter((cl: any) => cl.idCard === tc.id);
card.checklists = cardChecklists.map((cl: any) => ({
title: cl.name,
items: (cl.checkItems || []).map((item: any) => ({
title: item.name,
completed: item.state === 'complete'
}))
}));
}
columns[colIdx].cards.push(card);
}
} else {
// Pounce JSON import
boardTitle = importData.title || 'Imported Board';
columns = importData.columns || [];
const totalCards = columns.reduce((sum: number, col: ImportColumn) => sum + col.cards.length, 0);
if (totalCards > MAX_CARDS) {
throw error(400, `Too many cards (max ${MAX_CARDS})`);
}
}
const board = await withTenant(tenant.id, async (tx) => {
const cardPrefix = boardTitle.replace(/[^a-zA-Z]/g, '').toUpperCase().slice(0, 4) || 'CARD';
// Create the board
const newBoard = await tx.board.create({
data: {
tenantId: tenant.id,
title: boardTitle,
cardPrefix,
members: {
create: { userId: locals.user!.id, role: 'OWNER' }
}
}
});
// Get existing tags for this tenant
const existingTags = await tx.tag.findMany({
where: { tenantId: tenant.id }
});
const tagMap = new Map(existingTags.map((t) => [t.name.toLowerCase(), t]));
const colPositions = generatePositions(columns.length);
let nextCardNum = 1;
for (let ci = 0; ci < columns.length; ci++) {
const col = columns[ci];
const newCol = await tx.column.create({
data: {
boardId: newBoard.id,
title: col.title,
position: colPositions[ci]
}
});
const cardPositions = generatePositions(col.cards.length);
for (let cri = 0; cri < col.cards.length; cri++) {
const card = col.cards[cri];
const newCard = await tx.card.create({
data: {
columnId: newCol.id,
title: card.title,
description: card.description,
dueDate: card.dueDate ? new Date(card.dueDate) : null,
position: cardPositions[cri],
cardNumber: nextCardNum++
}
});
// Create labels
if (card.labels && card.labels.length > 0) {
for (const label of card.labels) {
let tag = tagMap.get(label.name.toLowerCase());
if (!tag) {
tag = await tx.tag.create({
data: {
tenantId: tenant.id,
name: label.name,
color: label.color || '#6b7280'
}
});
tagMap.set(label.name.toLowerCase(), tag);
}
await tx.cardLabel.create({
data: { cardId: newCard.id, tagId: tag.id }
});
}
}
// Create checklists
if (card.checklists && card.checklists.length > 0) {
const clPositions = generatePositions(card.checklists.length);
for (let cli = 0; cli < card.checklists.length; cli++) {
const cl = card.checklists[cli];
const newCl = await tx.checklist.create({
data: {
cardId: newCard.id,
title: cl.title,
position: clPositions[cli]
}
});
if (cl.items.length > 0) {
const itemPositions = generatePositions(cl.items.length);
await tx.checklistItem.createMany({
data: cl.items.map((item, idx) => ({
checklistId: newCl.id,
title: item.title,
completed: item.completed || false,
position: itemPositions[idx]
}))
});
}
}
}
}
}
// Update the board's nextCardNum
await tx.board.update({
where: { id: newBoard.id },
data: { nextCardNum: nextCardNum }
});
return newBoard;
});
return json({ board: { id: board.id, title: board.title } }, { status: 201 });
};