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
+155
View File
@@ -0,0 +1,155 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onBoardEvent, offBoardEvent } from '$lib/stores/socket.js';
type Props = {
boardId: string;
cardId?: string;
columnId?: string;
};
let { boardId, cardId, columnId }: Props = $props();
let activities = $state<any[]>([]);
let nextCursor = $state<string | null>(null);
let loading = $state(true);
let loadingMore = $state(false);
function formatAction(a: any): string {
const meta = a.metadata || {};
switch (a.action) {
case 'board.updated': return 'updated the board';
case 'board.deleted': return 'deleted the board';
case 'column.created': return `created column "${meta.title}"`;
case 'column.updated': return `renamed column to "${meta.title}"`;
case 'column.deleted': return `deleted column "${meta.title}"`;
case 'card.created': return `created card "${meta.title}"`;
case 'card.moved': return `moved card from "${meta.from}" to "${meta.to}"`;
case 'card.updated': return 'updated card';
case 'card.deleted': return `deleted card "${meta.title}"`;
case 'card.archived': return `archived card "${meta.title}"`;
case 'comment.added': return 'added a comment';
case 'comment.deleted': return 'deleted a comment';
case 'member.added': return `added ${meta.member} to the board`;
case 'member.removed': return `removed ${meta.member} from the board`;
case 'member.updated': return `changed ${meta.member}'s role to ${meta.to}`;
case 'assignee.added': return `assigned ${meta.assignee}`;
case 'assignee.removed': return `unassigned ${meta.assignee}`;
case 'label.added': return `added label "${meta.tag}"`;
case 'label.removed': return `removed label "${meta.tag}"`;
case 'checklist.created': return `created checklist "${meta.title}"`;
case 'checklist.deleted': return `deleted checklist "${meta.title}"`;
case 'checklist_item.added': return `added "${meta.title}" to checklist`;
case 'checklist_item.toggled': return meta.completed ? `completed "${meta.title}"` : `unchecked "${meta.title}"`;
case 'attachment.added': return `attached "${meta.filename}"`;
case 'attachment.deleted': return `removed attachment "${meta.filename}"`;
default: return a.action;
}
}
function timeAgo(dateStr: string): string {
const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
return new Date(dateStr).toLocaleDateString();
}
function buildUrl(): string {
if (cardId && columnId) {
return `/api/boards/${boardId}/columns/${columnId}/cards/${cardId}/activity`;
}
return `/api/boards/${boardId}/activity`;
}
async function fetchActivities(cursor?: string | null) {
const url = new URL(buildUrl(), window.location.origin);
url.searchParams.set('limit', '20');
if (cursor) url.searchParams.set('cursor', cursor);
const res = await fetch(url.toString());
if (!res.ok) return;
return res.json();
}
async function loadMore() {
loadingMore = true;
const data = await fetchActivities(nextCursor);
if (data) {
activities = [...activities, ...data.activities];
nextCursor = data.nextCursor;
}
loadingMore = false;
}
function onActivityNew(d: any) {
// If we're showing card-specific activity and this is for a different card, ignore
if (cardId && d.cardId && d.cardId !== cardId) return;
// Re-fetch latest to get the new entry
fetchActivities().then((data) => {
if (data) {
// Merge: prepend new ones that aren't already in our list
const existingIds = new Set(activities.map((a) => a.id));
const newOnes = data.activities.filter((a: any) => !existingIds.has(a.id));
activities = [...newOnes, ...activities];
}
});
}
onMount(async () => {
const data = await fetchActivities();
if (data) {
activities = data.activities;
nextCursor = data.nextCursor;
}
loading = false;
onBoardEvent('activity:new', onActivityNew);
return () => offBoardEvent('activity:new', onActivityNew);
});
</script>
<div class="space-y-3">
{#if loading}
<div class="animate-pulse space-y-2">
<div class="h-3 bg-gray-200 rounded w-3/4"></div>
<div class="h-3 bg-gray-200 rounded w-1/2"></div>
<div class="h-3 bg-gray-200 rounded w-2/3"></div>
</div>
{:else if activities.length === 0}
<p class="text-sm text-gray-400 text-center py-4">No activity yet</p>
{:else}
{#each activities as activity (activity.id)}
<div class="flex gap-2 text-sm">
<div
class="w-6 h-6 rounded-full bg-[var(--color-primary)] text-white text-[10px] flex items-center justify-center flex-shrink-0 mt-0.5"
title={activity.user.name}
>
{activity.user.name[0].toUpperCase()}
</div>
<div class="min-w-0">
<p class="text-gray-700 leading-snug">
<span class="font-medium">{activity.user.name}</span>
{' '}{formatAction(activity)}
</p>
<p class="text-xs text-gray-400 mt-0.5">{timeAgo(activity.createdAt)}</p>
</div>
</div>
{/each}
{#if nextCursor}
<button
onclick={loadMore}
disabled={loadingMore}
class="w-full text-center text-sm text-gray-500 hover:text-gray-700 py-2 transition"
>
{loadingMore ? 'Loading...' : 'Load more'}
</button>
{/if}
{/if}
</div>
+7
View File
@@ -7,6 +7,7 @@
import ChecklistSection from './ChecklistSection.svelte';
import CommentList from './CommentList.svelte';
import AttachmentList from './AttachmentList.svelte';
import ActivityFeed from './ActivityFeed.svelte';
type Props = {
card: any;
@@ -209,6 +210,12 @@
{currentUserId}
{canEdit}
/>
<!-- Activity Log -->
<div>
<h3 class="text-xs font-semibold text-gray-500 uppercase mb-2">Activity</h3>
<ActivityFeed {boardId} cardId={card.id} {columnId} />
</div>
{/if}
</div>
+151
View File
@@ -0,0 +1,151 @@
<script lang="ts">
import { onMount } from 'svelte';
import { getSocket } from '$lib/stores/socket.js';
let notifications = $state<any[]>([]);
let unreadCount = $state(0);
let open = $state(false);
let loading = $state(true);
function timeAgo(dateStr: string): string {
const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
async function fetchNotifications() {
const res = await fetch('/api/notifications?limit=10');
if (!res.ok) return;
const data = await res.json();
notifications = data.notifications;
unreadCount = data.unreadCount;
}
async function markAllRead() {
await fetch('/api/notifications/read-all', { method: 'POST' });
unreadCount = 0;
notifications = notifications.map((n) => ({ ...n, read: true }));
}
async function markRead(id: string) {
await fetch(`/api/notifications/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ read: true })
});
const n = notifications.find((x) => x.id === id);
if (n && !n.read) {
n.read = true;
unreadCount = Math.max(0, unreadCount - 1);
}
}
function onNewNotification(notification: any) {
notifications = [notification, ...notifications.slice(0, 9)];
unreadCount++;
}
function handleClickOutside(e: MouseEvent) {
const el = e.target as HTMLElement;
if (!el.closest('.notification-bell')) {
open = false;
}
}
onMount(async () => {
await fetchNotifications();
loading = false;
const socket = getSocket();
socket.on('notification:new', onNewNotification);
document.addEventListener('click', handleClickOutside);
return () => {
socket.off('notification:new', onNewNotification);
document.removeEventListener('click', handleClickOutside);
};
});
</script>
<div class="relative notification-bell">
<button
onclick={() => { open = !open; }}
class="relative rounded p-1.5 text-white/80 hover:text-white hover:bg-white/20 transition"
title="Notifications"
>
<svg class="w-5 h-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M10 2a6 6 0 00-6 6v3.586l-.707.707A1 1 0 004 14h12a1 1 0 00.707-1.707L16 11.586V8a6 6 0 00-6-6zM10 18a3 3 0 01-3-3h6a3 3 0 01-3 3z" />
</svg>
{#if unreadCount > 0}
<span class="absolute -top-0.5 -right-0.5 bg-red-500 text-white text-[10px] font-bold rounded-full w-4 h-4 flex items-center justify-center leading-none">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
{/if}
</button>
{#if open}
<div class="absolute right-0 top-full mt-1 w-80 bg-white rounded-lg shadow-xl border border-gray-200 z-50 overflow-hidden">
<div class="flex items-center justify-between px-3 py-2 border-b border-gray-100">
<span class="text-sm font-semibold text-gray-700">Notifications</span>
{#if unreadCount > 0}
<button
onclick={markAllRead}
class="text-xs text-[var(--color-primary)] hover:underline"
>
Mark all read
</button>
{/if}
</div>
<div class="max-h-80 overflow-y-auto">
{#if loading}
<div class="p-4 text-center text-sm text-gray-400">Loading...</div>
{:else if notifications.length === 0}
<div class="p-4 text-center text-sm text-gray-400">No notifications</div>
{:else}
{#each notifications as n (n.id)}
<a
href={n.link || '#'}
onclick={() => { if (!n.read) markRead(n.id); open = false; }}
class="block px-3 py-2.5 hover:bg-gray-50 transition border-b border-gray-50 last:border-0 {n.read ? '' : 'bg-blue-50/50'}"
>
<div class="flex items-start gap-2">
<div class="flex-shrink-0 mt-0.5">
{#if n.type === 'assigned'}
<svg class="w-4 h-4 text-[var(--color-primary)]" viewBox="0 0 20 20" fill="currentColor">
<path d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" />
</svg>
{:else if n.type === 'mentioned'}
<svg class="w-4 h-4 text-amber-500" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M14.243 5.757a6 6 0 10-.986 9.284 1 1 0 111.087 1.678A8 8 0 1118 10a3 3 0 01-4.8 2.401A4 4 0 1114 10a1 1 0 102 0c0-1.537-.586-3.07-1.757-4.243zM12 10a2 2 0 10-4 0 2 2 0 004 0z" clip-rule="evenodd" />
</svg>
{:else}
<svg class="w-4 h-4 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
<path d="M10 2a6 6 0 00-6 6v3.586l-.707.707A1 1 0 004 14h12a1 1 0 00.707-1.707L16 11.586V8a6 6 0 00-6-6z" />
</svg>
{/if}
</div>
<div class="min-w-0 flex-1">
<p class="text-sm text-gray-700 leading-snug">{n.title}</p>
{#if n.body}
<p class="text-xs text-gray-500 mt-0.5 truncate">{n.body}</p>
{/if}
<p class="text-xs text-gray-400 mt-0.5">{timeAgo(n.createdAt)}</p>
</div>
{#if !n.read}
<div class="w-2 h-2 rounded-full bg-[var(--color-primary)] flex-shrink-0 mt-1.5"></div>
{/if}
</div>
</a>
{/each}
{/if}
</div>
</div>
{/if}
</div>
+27
View File
@@ -0,0 +1,27 @@
import type { Prisma } from '@prisma/client';
type TxClient = Prisma.TransactionClient;
interface LogActivityOptions {
boardId: string;
userId: string;
action: string;
cardId?: string | null;
metadata?: Record<string, unknown>;
}
/**
* Log an activity entry inside an existing transaction.
* Call this within `withTenant(...)` so it shares the same RLS context.
*/
export async function logActivity(tx: TxClient, opts: LogActivityOptions) {
return tx.activity.create({
data: {
boardId: opts.boardId,
userId: opts.userId,
action: opts.action,
cardId: opts.cardId ?? undefined,
metadata: opts.metadata ?? undefined
}
});
}
+35
View File
@@ -0,0 +1,35 @@
import type { Prisma } from '@prisma/client';
import { notifyUser } from './realtime.js';
type TxClient = Prisma.TransactionClient;
interface NotifyOptions {
userId: string;
type: string;
title: string;
body?: string;
link?: string;
}
/**
* Create a notification and push it to the user via Socket.IO.
* Call within `withTenant(...)` so it shares the same RLS context.
*/
export async function notify(tx: TxClient, opts: NotifyOptions) {
const notification = await tx.notification.create({
data: {
userId: opts.userId,
type: opts.type,
title: opts.title,
body: opts.body,
link: opts.link
}
});
// Push to user in real-time (fire-and-forget, runs outside the tx)
queueMicrotask(() => {
notifyUser(opts.userId, 'notification:new', notification);
});
return notification;
}
+10
View File
@@ -12,3 +12,13 @@ export function broadcast(boardId: string, event: string, data: Record<string, u
if (!io) return;
io.to(`board:${boardId}`).emit(event, data);
}
/**
* Send an event to a specific user across all their connected sockets.
* Uses the `user:<userId>` room joined on connection.
*/
export function notifyUser(userId: string, event: string, data: Record<string, unknown> = {}) {
const io = globalThis.__socketIO;
if (!io) return;
io.to(`user:${userId}`).emit(event, data);
}
+10
View File
@@ -0,0 +1,10 @@
/**
* Extract unique @username mentions from text.
* Matches `@word` where word is alphanumeric, dot, dash, or underscore.
*/
export function parseMentions(text: string): string[] {
const matches = text.match(/@([\w.\-]+)/g);
if (!matches) return [];
const names = matches.map((m) => m.slice(1));
return [...new Set(names)];
}
+2
View File
@@ -3,6 +3,7 @@
import type { Snippet } from 'svelte';
import { onMount } from 'svelte';
import { connectSocket, disconnectSocket } from '$lib/stores/socket.js';
import NotificationBell from '$lib/components/NotificationBell.svelte';
let { children, data }: { children: Snippet; data: { user: any; tenant: any } } = $props();
@@ -39,6 +40,7 @@
</a>
{/if}
<span class="text-white/80 text-sm">{data.user.name}</span>
<NotificationBell />
<button
onclick={async () => {
await fetch('/api/auth/logout', { method: 'POST' });
@@ -6,6 +6,7 @@ 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;
@@ -78,6 +79,14 @@ export const DELETE: RequestHandler = async ({ params, request, locals }) => {
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 {
@@ -91,6 +100,7 @@ export const DELETE: RequestHandler = async ({ params, request, locals }) => {
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 });
};
+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 });
};
+37
View File
@@ -0,0 +1,37 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { withBypassRLS } from '$lib/server/rls.js';
import { requireAuth } from '$lib/server/guards.js';
export const GET: RequestHandler = async ({ url, locals }) => {
requireAuth(locals.user);
const limit = Math.min(Number(url.searchParams.get('limit')) || 20, 50);
const cursor = url.searchParams.get('cursor');
const unreadOnly = url.searchParams.get('unreadOnly') === 'true';
const result = await withBypassRLS(async (tx) => {
const where: Record<string, unknown> = { userId: locals.user!.id };
if (unreadOnly) where.read = false;
if (cursor) where.createdAt = { lt: new Date(cursor) };
const [notifications, unreadCount] = await Promise.all([
tx.notification.findMany({
where,
orderBy: { createdAt: 'desc' },
take: limit + 1
}),
tx.notification.count({
where: { userId: locals.user!.id, read: false }
})
]);
const hasMore = notifications.length > limit;
const items = hasMore ? notifications.slice(0, limit) : notifications;
const nextCursor = hasMore ? items[items.length - 1].createdAt.toISOString() : null;
return { notifications: items, nextCursor, unreadCount };
});
return json(result);
};
@@ -0,0 +1,25 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { withBypassRLS } from '$lib/server/rls.js';
import { requireAuth } from '$lib/server/guards.js';
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
requireAuth(locals.user);
const body = await request.json();
const notification = await withBypassRLS(async (tx) => {
const existing = await tx.notification.findUnique({
where: { id: params.notificationId }
});
if (!existing) throw error(404, 'Notification not found');
if (existing.userId !== locals.user!.id) throw error(403, 'Access denied');
return tx.notification.update({
where: { id: params.notificationId },
data: { read: body.read === true }
});
});
return json({ notification });
};
@@ -0,0 +1,17 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { withBypassRLS } from '$lib/server/rls.js';
import { requireAuth } from '$lib/server/guards.js';
export const POST: RequestHandler = async ({ locals }) => {
requireAuth(locals.user);
await withBypassRLS(async (tx) => {
await tx.notification.updateMany({
where: { userId: locals.user!.id, read: false },
data: { read: true }
});
});
return json({ ok: true });
};
+32
View File
@@ -6,6 +6,7 @@
import { getDueUrgency, getUrgencyClasses, formatDueDate } from '$lib/utils/due-date.js';
import { joinBoard, leaveBoard, onBoardEvent, offBoardEvent, getSocketId } from '$lib/stores/socket.js';
import CardModal from '$lib/components/CardModal.svelte';
import ActivityFeed from '$lib/components/ActivityFeed.svelte';
let { data } = $props();
@@ -25,6 +26,7 @@
let editingColumnId = $state<string | null>(null);
let editingColumnTitle = $state('');
let viewingUsers = $state<any[]>([]);
let showActivity = $state(false);
const flipDurationMs = 200;
@@ -349,8 +351,22 @@
</div>
</div>
{/if}
<button
onclick={() => (showActivity = !showActivity)}
class="ml-auto rounded px-2 py-1 text-sm text-gray-500 hover:bg-gray-200 hover:text-gray-700 transition flex items-center gap-1"
class:bg-gray-200={showActivity}
title="Toggle activity feed"
>
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd" />
</svg>
Activity
</button>
</div>
<!-- Main area: columns + optional activity sidebar -->
<div class="flex-1 flex overflow-hidden">
<!-- Columns container -->
<div class="flex-1 overflow-x-auto p-4">
<div
@@ -594,4 +610,20 @@
{/if}
</div>
</div>
<!-- Activity sidebar -->
{#if showActivity}
<div class="w-80 flex-shrink-0 border-l border-gray-200 bg-white overflow-y-auto p-4">
<div class="flex items-center justify-between mb-4">
<h2 class="text-sm font-semibold text-gray-700">Activity</h2>
<button onclick={() => (showActivity = false)} class="text-gray-400 hover:text-gray-600 transition p-1">
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
</div>
<ActivityFeed boardId={data.board.id} />
</div>
{/if}
</div>
</div>