Private
Public Access
1
0
Files
Kanban/src/routes/api/attachments/[attachmentId]/+server.ts
T
Catherine Renelle e5f2c64e14 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>
2026-02-26 00:26:15 -05:00

107 lines
3.2 KiB
TypeScript

import { 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';
import { readFile, unlink } from 'fs/promises';
import { json } from '@sveltejs/kit';
import { broadcast } from '$lib/server/realtime.js';
import { logActivity } from '$lib/server/activity.js';
export const GET: RequestHandler = async ({ params, locals }) => {
const tenant = locals.tenant;
if (!tenant) throw error(400, 'Unknown tenant');
const attachment = await withTenant(tenant.id, async (tx) => {
const att = await tx.attachment.findFirst({
where: { id: params.attachmentId },
include: {
card: {
include: {
column: {
include: {
board: {
select: { tenantId: true, visibility: true, members: true }
}
}
}
}
}
}
});
if (!att) throw error(404, 'Attachment not found');
if (att.card.column.board.tenantId !== tenant.id) throw error(404, 'Attachment not found');
if (!canViewBoard(locals.user, att.card.column.board.visibility, att.card.column.board.members)) {
if (!locals.user) throw error(401, 'Not authenticated');
throw error(403, 'Access denied');
}
return att;
});
const buffer = await readFile(attachment.filepath);
return new Response(buffer, {
headers: {
'Content-Type': attachment.mimetype,
'Content-Disposition': `attachment; filename="${attachment.filename}"`,
'Content-Length': String(attachment.size)
}
});
};
export const DELETE: RequestHandler = async ({ params, request, locals }) => {
const tenant = locals.tenant;
if (!tenant) throw error(400, 'Unknown tenant');
requireAuth(locals.user);
const deleted = await withTenant(tenant.id, async (tx) => {
const att = await tx.attachment.findFirst({
where: { id: params.attachmentId },
include: {
card: {
include: {
column: {
include: {
board: { select: { id: true, tenantId: true, members: true } }
}
}
}
}
}
});
if (!att) throw error(404, 'Attachment not found');
if (att.card.column.board.tenantId !== tenant.id) throw error(404, 'Attachment not found');
// Check board editor permission
const { canEditBoard } = await import('$lib/server/permissions.js');
if (!canEditBoard(locals.user, att.card.column.board.members)) {
throw error(403, 'Access denied');
}
await logActivity(tx, {
boardId: att.card.column.board.id,
userId: locals.user!.id,
action: 'attachment.deleted',
cardId: att.cardId,
metadata: { filename: att.filename }
});
await tx.attachment.delete({ where: { id: params.attachmentId } });
try {
await unlink(att.filepath);
} catch {
// File may already be gone
}
return { boardId: att.card.column.board.id, cardId: att.cardId, columnId: att.card.columnId };
});
const socketId = request.headers.get('X-Socket-ID');
broadcast(deleted.boardId, 'card:updated', { cardId: deleted.cardId, columnId: deleted.columnId, socketId });
broadcast(deleted.boardId, 'activity:new', { boardId: deleted.boardId, cardId: deleted.cardId, socketId });
return json({ ok: true });
};