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:
@@ -53,6 +53,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
}
|
||||
|
||||
const { title, description, visibility, categoryId, templateId } = result.data;
|
||||
const cardPrefix = title.replace(/[^a-zA-Z]/g, '').toUpperCase().slice(0, 4) || 'CARD';
|
||||
|
||||
// Load template columns if templateId provided
|
||||
let templateColumns: { title: string }[] = [];
|
||||
@@ -77,6 +78,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
title,
|
||||
description,
|
||||
visibility,
|
||||
cardPrefix,
|
||||
...(categoryId ? { categoryId } : {}),
|
||||
members: {
|
||||
create: {
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
+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 });
|
||||
};
|
||||
@@ -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"`
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
Reference in New Issue
Block a user