Phase 6: Activity log and notifications
Add activity logging to all write API routes, notification system with real-time delivery via Socket.IO, @mention parsing in comments, activity feed sidebar on board page, per-card activity in card modal, and notification bell with unread badge in the navbar. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import { boardSchema } from '$lib/server/validation.js';
|
||||
import { withTenant } from '$lib/server/rls.js';
|
||||
import { requireBoardView, requireBoardEditor, requireBoardOwner } from '$lib/server/guards.js';
|
||||
import { broadcast } from '$lib/server/realtime.js';
|
||||
import { logActivity } from '$lib/server/activity.js';
|
||||
|
||||
// Get single board with all columns and cards
|
||||
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
@@ -31,14 +32,24 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||
const updated = await withTenant(tenant.id, async (tx) => {
|
||||
await requireBoardEditor(tx, locals.user, params.boardId, tenant.id);
|
||||
|
||||
return tx.board.update({
|
||||
const board = await tx.board.update({
|
||||
where: { id: params.boardId },
|
||||
data: result.data
|
||||
});
|
||||
|
||||
await logActivity(tx, {
|
||||
boardId: params.boardId,
|
||||
userId: locals.user!.id,
|
||||
action: 'board.updated',
|
||||
metadata: result.data
|
||||
});
|
||||
|
||||
return board;
|
||||
});
|
||||
|
||||
const socketId = request.headers.get('X-Socket-ID');
|
||||
broadcast(params.boardId, 'board:updated', { board: updated, socketId });
|
||||
broadcast(params.boardId, 'activity:new', { boardId: params.boardId, socketId });
|
||||
|
||||
return json({ board: updated });
|
||||
};
|
||||
@@ -50,6 +61,13 @@ export const DELETE: RequestHandler = async ({ params, request, locals }) => {
|
||||
|
||||
await withTenant(tenant.id, async (tx) => {
|
||||
await requireBoardOwner(tx, locals.user, params.boardId, tenant.id);
|
||||
|
||||
await logActivity(tx, {
|
||||
boardId: params.boardId,
|
||||
userId: locals.user!.id,
|
||||
action: 'board.deleted'
|
||||
});
|
||||
|
||||
await tx.board.delete({ where: { id: params.boardId } });
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
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 limit = Math.min(Number(url.searchParams.get('limit')) || 20, 50);
|
||||
const cursor = url.searchParams.get('cursor');
|
||||
|
||||
const activities = await withTenant(tenant.id, async (tx) => {
|
||||
await requireBoardView(tx, locals.user, params.boardId, tenant.id);
|
||||
|
||||
return tx.activity.findMany({
|
||||
where: {
|
||||
boardId: params.boardId,
|
||||
...(cursor ? { createdAt: { lt: new Date(cursor) } } : {})
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit + 1,
|
||||
include: {
|
||||
user: { select: { id: true, name: true, avatarUrl: true } }
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const hasMore = activities.length > limit;
|
||||
const items = hasMore ? activities.slice(0, limit) : activities;
|
||||
const nextCursor = hasMore ? items[items.length - 1].createdAt.toISOString() : null;
|
||||
|
||||
return json({ activities: items, nextCursor });
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import { generatePosition } from '$lib/utils/fractional-index.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';
|
||||
|
||||
// Create column
|
||||
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
@@ -28,7 +29,7 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
|
||||
const position = generatePosition(lastColumn?.position, null);
|
||||
|
||||
return tx.column.create({
|
||||
const col = await tx.column.create({
|
||||
data: {
|
||||
boardId: params.boardId,
|
||||
title: result.data.title,
|
||||
@@ -38,10 +39,20 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
cards: true
|
||||
}
|
||||
});
|
||||
|
||||
await logActivity(tx, {
|
||||
boardId: params.boardId,
|
||||
userId: locals.user!.id,
|
||||
action: 'column.created',
|
||||
metadata: { title: result.data.title }
|
||||
});
|
||||
|
||||
return col;
|
||||
});
|
||||
|
||||
const socketId = request.headers.get('X-Socket-ID');
|
||||
broadcast(params.boardId, 'column:created', { column, socketId });
|
||||
broadcast(params.boardId, 'activity:new', { boardId: params.boardId, socketId });
|
||||
|
||||
return json({ column }, { status: 201 });
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { columnSchema, moveColumnSchema } from '$lib/server/validation.js';
|
||||
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';
|
||||
|
||||
// Update column title or position
|
||||
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||
@@ -32,14 +33,26 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||
throw error(400, result.error.issues[0].message);
|
||||
}
|
||||
|
||||
return tx.column.update({
|
||||
const col = await tx.column.update({
|
||||
where: { id: params.columnId },
|
||||
data: { title: result.data.title }
|
||||
});
|
||||
|
||||
await logActivity(tx, {
|
||||
boardId: params.boardId,
|
||||
userId: locals.user!.id,
|
||||
action: 'column.updated',
|
||||
metadata: { title: result.data.title }
|
||||
});
|
||||
|
||||
return col;
|
||||
});
|
||||
|
||||
const socketId = request.headers.get('X-Socket-ID');
|
||||
broadcast(params.boardId, 'column:updated', { column: updated, socketId });
|
||||
if (body.title !== undefined) {
|
||||
broadcast(params.boardId, 'activity:new', { boardId: params.boardId, socketId });
|
||||
}
|
||||
|
||||
return json({ column: updated });
|
||||
};
|
||||
@@ -50,12 +63,21 @@ export const DELETE: RequestHandler = async ({ params, request, locals }) => {
|
||||
if (!tenant) throw error(400, 'Unknown tenant');
|
||||
|
||||
await withTenant(tenant.id, async (tx) => {
|
||||
await requireColumnEditor(tx, locals.user, params.columnId, params.boardId, tenant.id);
|
||||
const col = await requireColumnEditor(tx, locals.user, params.columnId, params.boardId, tenant.id);
|
||||
|
||||
await logActivity(tx, {
|
||||
boardId: params.boardId,
|
||||
userId: locals.user!.id,
|
||||
action: 'column.deleted',
|
||||
metadata: { title: col.title }
|
||||
});
|
||||
|
||||
await tx.column.delete({ where: { id: params.columnId } });
|
||||
});
|
||||
|
||||
const socketId = request.headers.get('X-Socket-ID');
|
||||
broadcast(params.boardId, 'column:deleted', { columnId: params.columnId, socketId });
|
||||
broadcast(params.boardId, 'activity:new', { boardId: params.boardId, socketId });
|
||||
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { generatePosition } from '$lib/utils/fractional-index.js';
|
||||
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';
|
||||
|
||||
// Create card in column
|
||||
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
@@ -28,7 +29,7 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
|
||||
const position = generatePosition(lastCard?.position, null);
|
||||
|
||||
return tx.card.create({
|
||||
const newCard = await tx.card.create({
|
||||
data: {
|
||||
columnId: params.columnId,
|
||||
title: result.data.title,
|
||||
@@ -43,10 +44,21 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
_count: { select: { comments: true, checklists: true, attachments: true } }
|
||||
}
|
||||
});
|
||||
|
||||
await logActivity(tx, {
|
||||
boardId: params.boardId,
|
||||
userId: locals.user!.id,
|
||||
action: 'card.created',
|
||||
cardId: newCard.id,
|
||||
metadata: { title: result.data.title }
|
||||
});
|
||||
|
||||
return newCard;
|
||||
});
|
||||
|
||||
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 });
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { withTenant } from '$lib/server/rls.js';
|
||||
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';
|
||||
|
||||
// Get single card with full details
|
||||
export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
@@ -71,11 +72,17 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||
|
||||
// Handle move (column change + position)
|
||||
if (body.columnId !== undefined || body.position !== undefined) {
|
||||
// Look up source column name before moving
|
||||
const oldCard = await tx.card.findUnique({
|
||||
where: { id: params.cardId },
|
||||
select: { columnId: true, column: { select: { title: true } } }
|
||||
});
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (body.columnId) data.columnId = body.columnId;
|
||||
if (body.position) data.position = body.position;
|
||||
|
||||
return tx.card.update({
|
||||
const moved = await tx.card.update({
|
||||
where: { id: params.cardId },
|
||||
data,
|
||||
include: {
|
||||
@@ -83,9 +90,23 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||
include: { user: { select: { id: true, name: true, avatarUrl: true } } }
|
||||
},
|
||||
labels: { include: { tag: true } },
|
||||
column: { select: { title: true } },
|
||||
_count: { select: { comments: true, checklists: true, attachments: true } }
|
||||
}
|
||||
});
|
||||
|
||||
if (body.columnId && body.columnId !== oldCard?.columnId) {
|
||||
await logActivity(tx, {
|
||||
boardId: params.boardId,
|
||||
userId: locals.user!.id,
|
||||
action: 'card.moved',
|
||||
cardId: params.cardId,
|
||||
metadata: { from: oldCard?.column.title, to: moved.column.title }
|
||||
});
|
||||
}
|
||||
|
||||
const { column: _col, ...cardData } = moved;
|
||||
return cardData;
|
||||
}
|
||||
|
||||
// Handle content update
|
||||
@@ -98,7 +119,7 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||
if (body.dueDate !== undefined) updateData.dueDate = body.dueDate ? new Date(body.dueDate) : null;
|
||||
if (body.archived !== undefined) updateData.archived = body.archived;
|
||||
|
||||
return tx.card.update({
|
||||
const updated = await tx.card.update({
|
||||
where: { id: params.cardId },
|
||||
data: updateData,
|
||||
include: {
|
||||
@@ -109,10 +130,31 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||
_count: { select: { comments: true, checklists: true, attachments: true } }
|
||||
}
|
||||
});
|
||||
|
||||
if (body.archived === true) {
|
||||
await logActivity(tx, {
|
||||
boardId: params.boardId,
|
||||
userId: locals.user!.id,
|
||||
action: 'card.archived',
|
||||
cardId: params.cardId,
|
||||
metadata: { title: updated.title }
|
||||
});
|
||||
} else {
|
||||
await logActivity(tx, {
|
||||
boardId: params.boardId,
|
||||
userId: locals.user!.id,
|
||||
action: 'card.updated',
|
||||
cardId: params.cardId,
|
||||
metadata: { fields: Object.keys(result.data ?? {}) }
|
||||
});
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
const socketId = request.headers.get('X-Socket-ID');
|
||||
broadcast(params.boardId, 'card:updated', { card: updated, cardId: params.cardId, columnId: params.columnId, socketId });
|
||||
broadcast(params.boardId, 'activity:new', { boardId: params.boardId, cardId: params.cardId, socketId });
|
||||
|
||||
return json({ card: updated });
|
||||
};
|
||||
@@ -123,12 +165,21 @@ export const DELETE: RequestHandler = async ({ params, request, locals }) => {
|
||||
if (!tenant) throw error(400, 'Unknown tenant');
|
||||
|
||||
await withTenant(tenant.id, async (tx) => {
|
||||
await requireCardEditor(tx, locals.user, params.cardId, params.boardId, tenant.id);
|
||||
const card = await requireCardEditor(tx, locals.user, params.cardId, params.boardId, tenant.id);
|
||||
|
||||
await logActivity(tx, {
|
||||
boardId: params.boardId,
|
||||
userId: locals.user!.id,
|
||||
action: 'card.deleted',
|
||||
metadata: { title: card.title }
|
||||
});
|
||||
|
||||
await tx.card.delete({ where: { id: params.cardId } });
|
||||
});
|
||||
|
||||
const socketId = request.headers.get('X-Socket-ID');
|
||||
broadcast(params.boardId, 'card:deleted', { cardId: params.cardId, columnId: params.columnId, socketId });
|
||||
broadcast(params.boardId, 'activity:new', { boardId: params.boardId, socketId });
|
||||
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
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 limit = Math.min(Number(url.searchParams.get('limit')) || 20, 50);
|
||||
const cursor = url.searchParams.get('cursor');
|
||||
|
||||
const activities = await withTenant(tenant.id, async (tx) => {
|
||||
await requireBoardView(tx, locals.user, params.boardId, tenant.id);
|
||||
|
||||
return tx.activity.findMany({
|
||||
where: {
|
||||
boardId: params.boardId,
|
||||
cardId: params.cardId,
|
||||
...(cursor ? { createdAt: { lt: new Date(cursor) } } : {})
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit + 1,
|
||||
include: {
|
||||
user: { select: { id: true, name: true, avatarUrl: true } }
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const hasMore = activities.length > limit;
|
||||
const items = hasMore ? activities.slice(0, limit) : activities;
|
||||
const nextCursor = hasMore ? items[items.length - 1].createdAt.toISOString() : null;
|
||||
|
||||
return json({ activities: items, nextCursor });
|
||||
};
|
||||
+35
-2
@@ -4,6 +4,8 @@ import { cardAssigneeSchema } from '$lib/server/validation.js';
|
||||
import { withTenant } from '$lib/server/rls.js';
|
||||
import { requireCardEditor } from '$lib/server/guards.js';
|
||||
import { broadcast } from '$lib/server/realtime.js';
|
||||
import { logActivity } from '$lib/server/activity.js';
|
||||
import { notify } from '$lib/server/notifications.js';
|
||||
|
||||
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
const tenant = locals.tenant;
|
||||
@@ -22,14 +24,35 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
});
|
||||
if (!member) throw error(400, 'User is not a board member');
|
||||
|
||||
return tx.cardAssignee.create({
|
||||
const created = await tx.cardAssignee.create({
|
||||
data: { cardId: params.cardId, userId: result.data.userId },
|
||||
include: { user: { select: { id: true, name: true, email: true, avatarUrl: true } } }
|
||||
});
|
||||
|
||||
await logActivity(tx, {
|
||||
boardId: params.boardId,
|
||||
userId: locals.user!.id,
|
||||
action: 'assignee.added',
|
||||
cardId: params.cardId,
|
||||
metadata: { assignee: created.user.name }
|
||||
});
|
||||
|
||||
// Notify assigned user (skip self-assignment)
|
||||
if (result.data.userId !== locals.user!.id) {
|
||||
await notify(tx, {
|
||||
userId: result.data.userId,
|
||||
type: 'assigned',
|
||||
title: `${locals.user!.name} assigned you to "${card.title}"`,
|
||||
link: `/boards/${params.boardId}?card=${params.cardId}`
|
||||
});
|
||||
}
|
||||
|
||||
return created;
|
||||
});
|
||||
|
||||
const socketId = request.headers.get('X-Socket-ID');
|
||||
broadcast(params.boardId, 'card:updated', { cardId: params.cardId, columnId: params.columnId, socketId });
|
||||
broadcast(params.boardId, 'activity:new', { boardId: params.boardId, cardId: params.cardId, socketId });
|
||||
|
||||
return json({ assignee }, { status: 201 });
|
||||
};
|
||||
@@ -46,15 +69,25 @@ export const DELETE: RequestHandler = async ({ params, request, locals }) => {
|
||||
await requireCardEditor(tx, locals.user, params.cardId, params.boardId, tenant.id);
|
||||
|
||||
const assignee = await tx.cardAssignee.findFirst({
|
||||
where: { cardId: params.cardId, userId: result.data.userId }
|
||||
where: { cardId: params.cardId, userId: result.data.userId },
|
||||
include: { user: { select: { name: true } } }
|
||||
});
|
||||
if (!assignee) throw error(404, 'Assignee not found');
|
||||
|
||||
await logActivity(tx, {
|
||||
boardId: params.boardId,
|
||||
userId: locals.user!.id,
|
||||
action: 'assignee.removed',
|
||||
cardId: params.cardId,
|
||||
metadata: { assignee: assignee.user.name }
|
||||
});
|
||||
|
||||
await tx.cardAssignee.delete({ where: { id: assignee.id } });
|
||||
});
|
||||
|
||||
const socketId = request.headers.get('X-Socket-ID');
|
||||
broadcast(params.boardId, 'card:updated', { cardId: params.cardId, columnId: params.columnId, socketId });
|
||||
broadcast(params.boardId, 'activity:new', { boardId: params.boardId, cardId: params.cardId, socketId });
|
||||
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
+13
-1
@@ -6,6 +6,7 @@ import { writeFile, mkdir } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { broadcast } from '$lib/server/realtime.js';
|
||||
import { logActivity } from '$lib/server/activity.js';
|
||||
|
||||
const MAX_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const UPLOAD_ROOT = '/app/uploads';
|
||||
@@ -32,7 +33,7 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await writeFile(filepath, buffer);
|
||||
|
||||
return tx.attachment.create({
|
||||
const att = await tx.attachment.create({
|
||||
data: {
|
||||
cardId: params.cardId,
|
||||
filename: file.name,
|
||||
@@ -41,10 +42,21 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
size: file.size
|
||||
}
|
||||
});
|
||||
|
||||
await logActivity(tx, {
|
||||
boardId: params.boardId,
|
||||
userId: locals.user!.id,
|
||||
action: 'attachment.added',
|
||||
cardId: params.cardId,
|
||||
metadata: { filename: file.name }
|
||||
});
|
||||
|
||||
return att;
|
||||
});
|
||||
|
||||
const socketId = request.headers.get('X-Socket-ID');
|
||||
broadcast(params.boardId, 'card:updated', { cardId: params.cardId, columnId: params.columnId, socketId });
|
||||
broadcast(params.boardId, 'activity:new', { boardId: params.boardId, cardId: params.cardId, socketId });
|
||||
|
||||
return json({ attachment }, { status: 201 });
|
||||
};
|
||||
|
||||
+13
-1
@@ -5,6 +5,7 @@ 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;
|
||||
@@ -24,7 +25,7 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
|
||||
const position = generatePosition(last?.position, null);
|
||||
|
||||
return tx.checklist.create({
|
||||
const cl = await tx.checklist.create({
|
||||
data: {
|
||||
cardId: params.cardId,
|
||||
title: result.data.title,
|
||||
@@ -32,10 +33,21 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
},
|
||||
include: { items: true }
|
||||
});
|
||||
|
||||
await logActivity(tx, {
|
||||
boardId: params.boardId,
|
||||
userId: locals.user!.id,
|
||||
action: 'checklist.created',
|
||||
cardId: params.cardId,
|
||||
metadata: { title: result.data.title }
|
||||
});
|
||||
|
||||
return cl;
|
||||
});
|
||||
|
||||
const socketId = request.headers.get('X-Socket-ID');
|
||||
broadcast(params.boardId, 'card:updated', { cardId: params.cardId, columnId: params.columnId, socketId });
|
||||
broadcast(params.boardId, 'activity:new', { boardId: params.boardId, cardId: params.cardId, socketId });
|
||||
|
||||
return json({ checklist }, { status: 201 });
|
||||
};
|
||||
|
||||
+10
@@ -4,6 +4,7 @@ import { checklistSchema } from '$lib/server/validation.js';
|
||||
import { withTenant } from '$lib/server/rls.js';
|
||||
import { requireCardEditor } from '$lib/server/guards.js';
|
||||
import { broadcast } from '$lib/server/realtime.js';
|
||||
import { logActivity } from '$lib/server/activity.js';
|
||||
|
||||
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||
const tenant = locals.tenant;
|
||||
@@ -46,11 +47,20 @@ export const DELETE: RequestHandler = async ({ params, request, locals }) => {
|
||||
});
|
||||
if (!existing) throw error(404, 'Checklist not found');
|
||||
|
||||
await logActivity(tx, {
|
||||
boardId: params.boardId,
|
||||
userId: locals.user!.id,
|
||||
action: 'checklist.deleted',
|
||||
cardId: params.cardId,
|
||||
metadata: { title: existing.title }
|
||||
});
|
||||
|
||||
await tx.checklist.delete({ where: { id: params.checklistId } });
|
||||
});
|
||||
|
||||
const socketId = request.headers.get('X-Socket-ID');
|
||||
broadcast(params.boardId, 'card:updated', { cardId: params.cardId, columnId: params.columnId, socketId });
|
||||
broadcast(params.boardId, 'activity:new', { boardId: params.boardId, cardId: params.cardId, socketId });
|
||||
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
+12
-1
@@ -5,6 +5,7 @@ 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;
|
||||
@@ -29,13 +30,23 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
|
||||
const position = generatePosition(last?.position, null);
|
||||
|
||||
return tx.checklistItem.create({
|
||||
const newItem = await tx.checklistItem.create({
|
||||
data: {
|
||||
checklistId: params.checklistId,
|
||||
title: result.data.title,
|
||||
position
|
||||
}
|
||||
});
|
||||
|
||||
await logActivity(tx, {
|
||||
boardId: params.boardId,
|
||||
userId: locals.user!.id,
|
||||
action: 'checklist_item.added',
|
||||
cardId: params.cardId,
|
||||
metadata: { title: result.data.title }
|
||||
});
|
||||
|
||||
return newItem;
|
||||
});
|
||||
|
||||
const socketId = request.headers.get('X-Socket-ID');
|
||||
|
||||
+14
-1
@@ -3,6 +3,7 @@ import type { RequestHandler } from './$types.js';
|
||||
import { withTenant } from '$lib/server/rls.js';
|
||||
import { requireCardEditor } from '$lib/server/guards.js';
|
||||
import { broadcast } from '$lib/server/realtime.js';
|
||||
import { logActivity } from '$lib/server/activity.js';
|
||||
|
||||
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||
const tenant = locals.tenant;
|
||||
@@ -26,10 +27,22 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||
if (typeof body.completed === 'boolean') data.completed = body.completed;
|
||||
if (typeof body.position === 'string') data.position = body.position;
|
||||
|
||||
return tx.checklistItem.update({
|
||||
const updated = await tx.checklistItem.update({
|
||||
where: { id: params.itemId },
|
||||
data
|
||||
});
|
||||
|
||||
if (typeof body.completed === 'boolean') {
|
||||
await logActivity(tx, {
|
||||
boardId: params.boardId,
|
||||
userId: locals.user!.id,
|
||||
action: 'checklist_item.toggled',
|
||||
cardId: params.cardId,
|
||||
metadata: { title: updated.title, completed: body.completed }
|
||||
});
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
const socketId = request.headers.get('X-Socket-ID');
|
||||
|
||||
+38
-2
@@ -4,6 +4,9 @@ import { commentSchema } from '$lib/server/validation.js';
|
||||
import { withTenant } from '$lib/server/rls.js';
|
||||
import { requireCardEditor } from '$lib/server/guards.js';
|
||||
import { broadcast } from '$lib/server/realtime.js';
|
||||
import { logActivity } from '$lib/server/activity.js';
|
||||
import { notify } from '$lib/server/notifications.js';
|
||||
import { parseMentions } from '$lib/utils/mentions.js';
|
||||
|
||||
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
const tenant = locals.tenant;
|
||||
@@ -14,9 +17,9 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
if (!result.success) throw error(400, result.error.issues[0].message);
|
||||
|
||||
const comment = await withTenant(tenant.id, async (tx) => {
|
||||
await requireCardEditor(tx, locals.user, params.cardId, params.boardId, tenant.id);
|
||||
const card = await requireCardEditor(tx, locals.user, params.cardId, params.boardId, tenant.id);
|
||||
|
||||
return tx.comment.create({
|
||||
const created = await tx.comment.create({
|
||||
data: {
|
||||
cardId: params.cardId,
|
||||
userId: locals.user!.id,
|
||||
@@ -26,10 +29,43 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
user: { select: { id: true, name: true, avatarUrl: true } }
|
||||
}
|
||||
});
|
||||
|
||||
await logActivity(tx, {
|
||||
boardId: params.boardId,
|
||||
userId: locals.user!.id,
|
||||
action: 'comment.added',
|
||||
cardId: params.cardId
|
||||
});
|
||||
|
||||
// Parse @mentions and notify
|
||||
const mentions = parseMentions(result.data.content);
|
||||
if (mentions.length > 0) {
|
||||
const mentionedUsers = await tx.user.findMany({
|
||||
where: {
|
||||
name: { in: mentions },
|
||||
tenantId: tenant.id
|
||||
},
|
||||
select: { id: true, name: true }
|
||||
});
|
||||
|
||||
for (const u of mentionedUsers) {
|
||||
if (u.id !== locals.user!.id) {
|
||||
await notify(tx, {
|
||||
userId: u.id,
|
||||
type: 'mentioned',
|
||||
title: `${locals.user!.name} mentioned you in a comment on "${card.title}"`,
|
||||
link: `/boards/${params.boardId}?card=${params.cardId}`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return created;
|
||||
});
|
||||
|
||||
const socketId = request.headers.get('X-Socket-ID');
|
||||
broadcast(params.boardId, 'card:updated', { cardId: params.cardId, columnId: params.columnId, socketId });
|
||||
broadcast(params.boardId, 'activity:new', { boardId: params.boardId, cardId: params.cardId, socketId });
|
||||
|
||||
return json({ comment }, { status: 201 });
|
||||
};
|
||||
|
||||
+9
@@ -4,6 +4,7 @@ import { commentSchema } from '$lib/server/validation.js';
|
||||
import { withTenant } from '$lib/server/rls.js';
|
||||
import { requireCardEditor, requireAuth } from '$lib/server/guards.js';
|
||||
import { broadcast } from '$lib/server/realtime.js';
|
||||
import { logActivity } from '$lib/server/activity.js';
|
||||
|
||||
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||
const tenant = locals.tenant;
|
||||
@@ -61,11 +62,19 @@ export const DELETE: RequestHandler = async ({ params, request, locals }) => {
|
||||
requireAuth(locals.user);
|
||||
}
|
||||
|
||||
await logActivity(tx, {
|
||||
boardId: params.boardId,
|
||||
userId: locals.user!.id,
|
||||
action: 'comment.deleted',
|
||||
cardId: params.cardId
|
||||
});
|
||||
|
||||
await tx.comment.delete({ where: { id: params.commentId } });
|
||||
});
|
||||
|
||||
const socketId = request.headers.get('X-Socket-ID');
|
||||
broadcast(params.boardId, 'card:updated', { cardId: params.cardId, columnId: params.columnId, socketId });
|
||||
broadcast(params.boardId, 'activity:new', { boardId: params.boardId, cardId: params.cardId, socketId });
|
||||
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
+24
-2
@@ -4,6 +4,7 @@ import { cardLabelSchema } from '$lib/server/validation.js';
|
||||
import { withTenant } from '$lib/server/rls.js';
|
||||
import { requireCardEditor } from '$lib/server/guards.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;
|
||||
@@ -21,14 +22,25 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
});
|
||||
if (!tag) throw error(404, 'Tag not found');
|
||||
|
||||
return tx.cardLabel.create({
|
||||
const cardLabel = await tx.cardLabel.create({
|
||||
data: { cardId: params.cardId, tagId: result.data.tagId },
|
||||
include: { tag: true }
|
||||
});
|
||||
|
||||
await logActivity(tx, {
|
||||
boardId: params.boardId,
|
||||
userId: locals.user!.id,
|
||||
action: 'label.added',
|
||||
cardId: params.cardId,
|
||||
metadata: { tag: tag.name }
|
||||
});
|
||||
|
||||
return cardLabel;
|
||||
});
|
||||
|
||||
const socketId = request.headers.get('X-Socket-ID');
|
||||
broadcast(params.boardId, 'card:updated', { cardId: params.cardId, columnId: params.columnId, socketId });
|
||||
broadcast(params.boardId, 'activity:new', { boardId: params.boardId, cardId: params.cardId, socketId });
|
||||
|
||||
return json({ label }, { status: 201 });
|
||||
};
|
||||
@@ -45,15 +57,25 @@ export const DELETE: RequestHandler = async ({ params, request, locals }) => {
|
||||
await requireCardEditor(tx, locals.user, params.cardId, params.boardId, tenant.id);
|
||||
|
||||
const label = await tx.cardLabel.findFirst({
|
||||
where: { cardId: params.cardId, tagId: result.data.tagId }
|
||||
where: { cardId: params.cardId, tagId: result.data.tagId },
|
||||
include: { tag: true }
|
||||
});
|
||||
if (!label) throw error(404, 'Label not found');
|
||||
|
||||
await logActivity(tx, {
|
||||
boardId: params.boardId,
|
||||
userId: locals.user!.id,
|
||||
action: 'label.removed',
|
||||
cardId: params.cardId,
|
||||
metadata: { tag: label.tag.name }
|
||||
});
|
||||
|
||||
await tx.cardLabel.delete({ where: { id: label.id } });
|
||||
});
|
||||
|
||||
const socketId = request.headers.get('X-Socket-ID');
|
||||
broadcast(params.boardId, 'card:updated', { cardId: params.cardId, columnId: params.columnId, socketId });
|
||||
broadcast(params.boardId, 'activity:new', { boardId: params.boardId, cardId: params.cardId, socketId });
|
||||
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
@@ -4,6 +4,8 @@ import { addMemberSchema } from '$lib/server/validation.js';
|
||||
import { withTenant } from '$lib/server/rls.js';
|
||||
import { requireBoardMemberManager } from '$lib/server/guards.js';
|
||||
import { broadcast } from '$lib/server/realtime.js';
|
||||
import { logActivity } from '$lib/server/activity.js';
|
||||
import { notify } from '$lib/server/notifications.js';
|
||||
|
||||
// Add member by email
|
||||
export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
@@ -19,7 +21,7 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
const { email, role } = result.data;
|
||||
|
||||
const member = await withTenant(tenant.id, async (tx) => {
|
||||
await requireBoardMemberManager(tx, locals.user, params.boardId, tenant.id);
|
||||
const board = await requireBoardMemberManager(tx, locals.user, params.boardId, tenant.id);
|
||||
|
||||
// Find user by email within this tenant
|
||||
const targetUser = await tx.user.findFirst({
|
||||
@@ -33,7 +35,7 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
});
|
||||
if (existing) throw error(409, 'User is already a member of this board');
|
||||
|
||||
return tx.boardMember.create({
|
||||
const created = await tx.boardMember.create({
|
||||
data: {
|
||||
boardId: params.boardId,
|
||||
userId: targetUser.id,
|
||||
@@ -43,10 +45,30 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
|
||||
user: { select: { id: true, name: true, email: true, avatarUrl: true } }
|
||||
}
|
||||
});
|
||||
|
||||
await logActivity(tx, {
|
||||
boardId: params.boardId,
|
||||
userId: locals.user!.id,
|
||||
action: 'member.added',
|
||||
metadata: { member: targetUser.name, role }
|
||||
});
|
||||
|
||||
// Notify added user
|
||||
if (targetUser.id !== locals.user!.id) {
|
||||
await notify(tx, {
|
||||
userId: targetUser.id,
|
||||
type: 'added_to_board',
|
||||
title: `${locals.user!.name} added you to board "${board.title}"`,
|
||||
link: `/boards/${params.boardId}`
|
||||
});
|
||||
}
|
||||
|
||||
return created;
|
||||
});
|
||||
|
||||
const socketId = request.headers.get('X-Socket-ID');
|
||||
broadcast(params.boardId, 'member:added', { member, socketId });
|
||||
broadcast(params.boardId, 'activity:new', { boardId: params.boardId, socketId });
|
||||
|
||||
return json({ member }, { status: 201 });
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { changeMemberRoleSchema } from '$lib/server/validation.js';
|
||||
import { withTenant } from '$lib/server/rls.js';
|
||||
import { requireBoardMemberManager } from '$lib/server/guards.js';
|
||||
import { broadcast } from '$lib/server/realtime.js';
|
||||
import { logActivity } from '$lib/server/activity.js';
|
||||
|
||||
// Change member role
|
||||
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||
@@ -20,7 +21,8 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||
await requireBoardMemberManager(tx, locals.user, params.boardId, tenant.id);
|
||||
|
||||
const member = await tx.boardMember.findFirst({
|
||||
where: { id: params.memberId, boardId: params.boardId }
|
||||
where: { id: params.memberId, boardId: params.boardId },
|
||||
include: { user: { select: { name: true } } }
|
||||
});
|
||||
if (!member) throw error(404, 'Member not found');
|
||||
|
||||
@@ -32,17 +34,27 @@ export const PATCH: RequestHandler = async ({ params, request, locals }) => {
|
||||
if (ownerCount <= 1) throw error(400, 'Cannot demote the last owner');
|
||||
}
|
||||
|
||||
return tx.boardMember.update({
|
||||
const updated = await tx.boardMember.update({
|
||||
where: { id: params.memberId },
|
||||
data: { role: result.data.role },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true, avatarUrl: true } }
|
||||
}
|
||||
});
|
||||
|
||||
await logActivity(tx, {
|
||||
boardId: params.boardId,
|
||||
userId: locals.user!.id,
|
||||
action: 'member.updated',
|
||||
metadata: { member: member.user.name, from: member.role, to: result.data.role }
|
||||
});
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
const socketId = request.headers.get('X-Socket-ID');
|
||||
broadcast(params.boardId, 'member:updated', { member: updated, socketId });
|
||||
broadcast(params.boardId, 'activity:new', { boardId: params.boardId, socketId });
|
||||
|
||||
return json({ member: updated });
|
||||
};
|
||||
@@ -56,7 +68,8 @@ export const DELETE: RequestHandler = async ({ params, request, locals }) => {
|
||||
await requireBoardMemberManager(tx, locals.user, params.boardId, tenant.id);
|
||||
|
||||
const member = await tx.boardMember.findFirst({
|
||||
where: { id: params.memberId, boardId: params.boardId }
|
||||
where: { id: params.memberId, boardId: params.boardId },
|
||||
include: { user: { select: { name: true } } }
|
||||
});
|
||||
if (!member) throw error(404, 'Member not found');
|
||||
|
||||
@@ -68,11 +81,19 @@ export const DELETE: RequestHandler = async ({ params, request, locals }) => {
|
||||
if (ownerCount <= 1) throw error(400, 'Cannot remove the last owner');
|
||||
}
|
||||
|
||||
await logActivity(tx, {
|
||||
boardId: params.boardId,
|
||||
userId: locals.user!.id,
|
||||
action: 'member.removed',
|
||||
metadata: { member: member.user.name }
|
||||
});
|
||||
|
||||
await tx.boardMember.delete({ where: { id: params.memberId } });
|
||||
});
|
||||
|
||||
const socketId = request.headers.get('X-Socket-ID');
|
||||
broadcast(params.boardId, 'member:removed', { memberId: params.memberId, socketId });
|
||||
broadcast(params.boardId, 'activity:new', { boardId: params.boardId, socketId });
|
||||
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user