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:
@@ -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,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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)];
|
||||
}
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user