Private
Public Access
1
0

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:
Catherine Renelle
2026-02-26 00:26:15 -05:00
parent e61c549ddb
commit e5f2c64e14
32 changed files with 930 additions and 23 deletions
+19 -1
View File
@@ -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 });
};
@@ -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 });
};
@@ -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 });
};
@@ -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 });
};
@@ -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 });
};
@@ -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');
@@ -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');
@@ -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 });
};
@@ -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 });
};
@@ -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 });
};