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:
@@ -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 });
|
||||
};
|
||||
+49
@@ -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 });
|
||||
};
|
||||
Reference in New Issue
Block a user