diff --git a/README.md b/README.md index a49833d..a58acd2 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,11 @@ Built because we needed PureRef's painlessness, Miro's collaboration, and a code - Threaded comments with status (open / resolved) - Review mode toggles a clean overlay for walking through feedback +**Activity log** +- Per-board audit trail visible from the toolbar — uploads, board renames, threads, replies +- Live updates over Socket.IO (no refresh needed when collaborators are working) +- Time-grouped feed (Today / Yesterday / older), pagination, deactivation-safe author labels + **Media pipeline** - Image variants (thumbnail / hires / LOD) generated on upload via Sharp - Video poster + duration + dimensions extracted via ffmpeg @@ -283,7 +288,7 @@ See [CHANGELOG.md](CHANGELOG.md) for the version history (v0.1.0 → v0.5.0). ## Roadmap - [x] Admin dashboard frontend (live at `/admin` — user create / reset-password / role / deactivate) -- [ ] Per-board activity log (who added/deleted what, when) +- [x] Per-board activity log (uploads, board events, threads, comments — live via Socket.IO) - [ ] Mobile-friendly read-only board view - [ ] Export board → PDF / image grid - [ ] Optional remote storage adapters (S3 direct, R2) diff --git a/backend/activity.js b/backend/activity.js new file mode 100644 index 0000000..d1d795a --- /dev/null +++ b/backend/activity.js @@ -0,0 +1,55 @@ +/** + * activity.js — single helper used by route handlers to append an entry to + * the per-board audit log AND broadcast it over Socket.IO to anyone in the + * room so live activity panels update without polling. + */ + +const { logActivity } = require('./db'); +const { getRoomName } = require('./socket/board-room'); + +/** + * Record + broadcast a board activity event. + * + * @param {object} req — Express req (used for app.get('io') and req.user) + * @param {object} entry — { boardId, action, targetType?, targetId?, targetLabel?, metadata? } + * userId is taken from req.user.id automatically. + */ +function recordActivity(req, entry) { + try { + const userId = entry.userId !== undefined ? entry.userId : req?.user?.id || null; + const row = logActivity({ ...entry, userId }); + if (!row) return null; + + const io = req?.app?.get?.('io'); + if (io && entry.boardId) { + io.to(getRoomName(entry.boardId)).emit('activity:new', formatRow(row)); + } + return row; + } catch (err) { + // Never let activity logging break a mutation. Just log + swallow. + console.error('[activity] failed:', err.message); + return null; + } +} + +function formatRow(row) { + return { + id: row.id, + board_id: row.board_id, + user_id: row.user_id, + actor_name: row.actor_name, + actor_email: row.actor_email, + action: row.action, + target_type: row.target_type, + target_id: row.target_id, + target_label: row.target_label, + metadata: row.metadata ? safeParse(row.metadata) : null, + created_at: row.created_at, + }; +} + +function safeParse(s) { + try { return JSON.parse(s); } catch { return null; } +} + +module.exports = { recordActivity, formatRow }; diff --git a/backend/db.js b/backend/db.js index c05da98..e52a791 100644 --- a/backend/db.js +++ b/backend/db.js @@ -207,6 +207,82 @@ catch { db.exec('ALTER TABLE images ADD COLUMN page_count INTEGER'); } try { db.prepare('SELECT priority FROM media_jobs LIMIT 0').get(); } catch { db.exec('ALTER TABLE media_jobs ADD COLUMN priority INTEGER DEFAULT 0'); } +// --------------------- +// Activity log (per-board audit trail) +// --------------------- +db.exec(` + CREATE TABLE IF NOT EXISTS activity_logs ( + id TEXT PRIMARY KEY, + board_id TEXT NOT NULL, + user_id TEXT, + actor_name TEXT, + actor_email TEXT, + action TEXT NOT NULL, + target_type TEXT, + target_id TEXT, + target_label TEXT, + metadata TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE INDEX IF NOT EXISTS idx_activity_board_time + ON activity_logs(board_id, created_at DESC); +`); + +const { v4: _activityUuid } = require('uuid'); + +/** + * Append a single activity entry. Resolves user → denormalised actor fields + * at log time so the entry survives user rename / deactivation. + */ +function logActivity({ boardId, userId, action, targetType, targetId, targetLabel, metadata }) { + if (!boardId || !action) return null; + let actorName = null; + let actorEmail = null; + if (userId) { + const user = db.prepare('SELECT display_name, email FROM users WHERE id = ?').get(userId); + if (user) { + actorName = user.display_name; + actorEmail = user.email; + } + } + const id = _activityUuid(); + db.prepare(` + INSERT INTO activity_logs (id, board_id, user_id, actor_name, actor_email, action, target_type, target_id, target_label, metadata) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + id, + boardId, + userId || null, + actorName, + actorEmail, + action, + targetType || null, + targetId || null, + targetLabel || null, + metadata ? JSON.stringify(metadata) : null, + ); + return db.prepare('SELECT * FROM activity_logs WHERE id = ?').get(id); +} + +function getBoardActivity(boardId, { limit = 50, before = null } = {}) { + const lim = Math.max(1, Math.min(parseInt(limit, 10) || 50, 200)); + if (before) { + return db.prepare(` + SELECT * FROM activity_logs + WHERE board_id = ? AND created_at < ? + ORDER BY created_at DESC + LIMIT ? + `).all(boardId, before, lim); + } + return db.prepare(` + SELECT * FROM activity_logs + WHERE board_id = ? + ORDER BY created_at DESC + LIMIT ? + `).all(boardId, lim); +} + // --------------------- // Settings (runtime-tunable key/value pairs) // --------------------- @@ -747,6 +823,8 @@ module.exports = { getCommentsByThread, getCommentsByBoard, getComment, createComment, updateComment, deleteComment, // Settings getSetting, setSetting, getAllSettings, getBoolSetting, + // Activity log + logActivity, getBoardActivity, // Bootstrap seedAdminFromEnv, }; diff --git a/backend/routes/activity.js b/backend/routes/activity.js new file mode 100644 index 0000000..4823790 --- /dev/null +++ b/backend/routes/activity.js @@ -0,0 +1,35 @@ +const { Router } = require('express'); +const { authMiddleware } = require('../auth'); +const { getBoardActivity } = require('../db'); +const { resolveBoard } = require('./board-access'); +const { formatRow } = require('../activity'); + +const router = Router(); +router.use(authMiddleware); + +/** + * GET /api/boards/:boardId/activity + * Returns recent activity entries for a board, newest first. + * + * Query params: + * limit — max entries to return (1-200, default 50) + * before — ISO timestamp; only entries created strictly before this + */ +router.get('/:boardId/activity', (req, res) => { + try { + const result = resolveBoard(req, res, 'viewer'); + if (!result) return; + + const { limit, before } = req.query; + const rows = getBoardActivity(result.board.id, { limit, before }); + return res.json({ + activity: rows.map(formatRow), + hasMore: rows.length === Math.max(1, Math.min(parseInt(limit, 10) || 50, 200)), + }); + } catch (err) { + console.error('[activity] list error:', err); + return res.status(500).json({ error: 'Internal server error' }); + } +}); + +module.exports = router; diff --git a/backend/routes/boards.js b/backend/routes/boards.js index 9b659c8..c2d04b3 100644 --- a/backend/routes/boards.js +++ b/backend/routes/boards.js @@ -12,6 +12,7 @@ const { getCollectionMember, } = require('../db'); const { deleteBoardImages: deleteBoardMinioImages } = require('../minio'); +const { recordActivity } = require('../activity'); const router = Router(); @@ -81,6 +82,14 @@ router.post('/', (req, res) => { createdBy: req.user.id, }); + recordActivity(req, { + boardId: board.id, + action: 'board.created', + targetType: 'board', + targetId: board.id, + targetLabel: board.name, + }); + return res.status(201).json({ board }); } catch (err) { console.error('[boards] create error:', err); @@ -171,8 +180,20 @@ router.put('/:boardId', (req, res) => { if (!result) return; const { name, description } = req.body; + const prevName = result.board.name; const updated = updateBoard(result.board.id, { name, description }); + if (typeof name === 'string' && name.trim() && name.trim() !== prevName) { + recordActivity(req, { + boardId: updated.id, + action: 'board.renamed', + targetType: 'board', + targetId: updated.id, + targetLabel: updated.name, + metadata: { from: prevName, to: updated.name }, + }); + } + return res.json({ board: updated }); } catch (err) { console.error('[boards] update error:', err); @@ -195,6 +216,14 @@ router.delete('/:boardId', async (req, res) => { console.error('[boards] MinIO cleanup error:', e); } + recordActivity(req, { + boardId: result.board.id, + action: 'board.deleted', + targetType: 'board', + targetId: result.board.id, + targetLabel: result.board.name, + }); + deleteBoard(result.board.id); return res.json({ message: 'Board deleted' }); diff --git a/backend/routes/threads.js b/backend/routes/threads.js index 803598f..7537659 100644 --- a/backend/routes/threads.js +++ b/backend/routes/threads.js @@ -18,6 +18,7 @@ const { getUserById, } = require('../db'); const { hasCollectionRole, resolveBoard } = require('./board-access'); +const { recordActivity } = require('../activity'); function resolveAuthorName(reqUser) { if (reqUser.display_name || reqUser.username) { @@ -108,6 +109,14 @@ router.post('/:boardId/threads', (req, res) => { }); } + recordActivity(req, { + boardId: req.params.boardId, + action: 'thread.created', + targetType: 'thread', + targetId: threadId, + targetLabel: content.trim().slice(0, 80), + }); + return res.status(201).json({ thread, comment }); } catch (err) { console.error('[threads] create error:', err); @@ -144,6 +153,15 @@ router.patch('/:boardId/threads/:threadId', (req, res) => { }); } + if (status === 'resolved' || status === 'open') { + recordActivity(req, { + boardId: req.params.boardId, + action: status === 'resolved' ? 'thread.resolved' : 'thread.reopened', + targetType: 'thread', + targetId: req.params.threadId, + }); + } + return res.json({ thread: updated }); } catch (err) { console.error('[threads] status error:', err); @@ -221,6 +239,14 @@ router.post('/:boardId/threads/:threadId/comments', (req, res) => { }); } + recordActivity(req, { + boardId: req.params.boardId, + action: 'comment.added', + targetType: 'thread', + targetId: req.params.threadId, + targetLabel: content.trim().slice(0, 80), + }); + return res.status(201).json({ comment }); } catch (err) { console.error('[threads] add comment error:', err); diff --git a/backend/routes/upload.js b/backend/routes/upload.js index d809822..e99c021 100644 --- a/backend/routes/upload.js +++ b/backend/routes/upload.js @@ -6,9 +6,10 @@ const https = require('https'); const http = require('http'); const { URL } = require('url'); const { authMiddleware } = require('../auth'); -const { getBoard, getCollectionMember, createImage, createMediaJob, createPdfPage, updateImagePageCount } = require('../db'); +const { getBoard, getCollectionMember, createImage, getImage, createMediaJob, createPdfPage, updateImagePageCount } = require('../db'); const { putBuffer, getImageUrl, MIME_TO_EXT, MAX_FILE_SIZE } = require('../minio'); const { pdfInfo, bufferToTempFile } = require('../pdf-utils'); +const { recordActivity } = require('../activity'); const router = Router(); @@ -192,6 +193,15 @@ router.post('/boards/:boardId/images', upload.single('image'), async (req, res) }); } + recordActivity(req, { + boardId: board.id, + action: 'pdf.added', + targetType: 'pdf', + targetId: imageId, + targetLabel: originalname, + metadata: { pageCount: info.pageCount, fileSize: size }, + }); + return res.status(201).json({ id: image.id, media_type: 'pdf', @@ -233,6 +243,15 @@ router.post('/boards/:boardId/images', upload.single('image'), async (req, res) createMediaJob({ id: jobId, imageId, boardId: board.id, type: 'poster' }); } + recordActivity(req, { + boardId: board.id, + action: `${mediaType}.added`, + targetType: mediaType, + targetId: imageId, + targetLabel: originalname, + metadata: { fileSize: size, mimeType: mimetype, source: 'upload' }, + }); + return res.status(201).json({ id: image.id, url: publicUrl, @@ -354,6 +373,15 @@ router.post('/boards/:boardId/images/from-url', async (req, res) => { createMediaJob({ id: jobId, imageId, boardId: board.id, type: 'poster' }); } + recordActivity(req, { + boardId: board.id, + action: `${mediaType}.added`, + targetType: mediaType, + targetId: imageId, + targetLabel: filename, + metadata: { fileSize: buffer.length, mimeType, source: 'url', sourceUrl: url }, + }); + return res.status(201).json({ id: image.id, url: publicUrl, diff --git a/backend/server.js b/backend/server.js index d6748ef..d0dfb02 100644 --- a/backend/server.js +++ b/backend/server.js @@ -101,6 +101,7 @@ const uploadRoutes = require('./routes/upload'); const adminRoutes = require('./routes/admin'); const threadRoutes = require('./routes/threads'); const pdfRoutes = require('./routes/pdf'); +const activityRoutes = require('./routes/activity'); app.use('/api/auth', authRoutes); app.use('/api/collections', collectionRoutes); @@ -109,6 +110,7 @@ app.use('/api/upload', uploadRoutes); app.use('/api/admin', adminRoutes); app.use('/api/boards', threadRoutes); app.use('/api/boards', pdfRoutes); +app.use('/api/boards', activityRoutes); // Public shared collection route (no auth required) app.get('/api/c/:shareToken', (req, res) => { diff --git a/backend/socket/board-room.js b/backend/socket/board-room.js index b06200b..abcaf73 100644 --- a/backend/socket/board-room.js +++ b/backend/socket/board-room.js @@ -182,4 +182,4 @@ function leaveRoom(io, socket, roomName) { console.log(`[socket] ${socket.userDisplayName} left ${roomName}`); } -module.exports = { setupBoardRoom }; +module.exports = { setupBoardRoom, getRoomName }; diff --git a/frontend/src/components/ActivityPanel.tsx b/frontend/src/components/ActivityPanel.tsx new file mode 100644 index 0000000..cb1b018 --- /dev/null +++ b/frontend/src/components/ActivityPanel.tsx @@ -0,0 +1,256 @@ +import React, { useEffect, useRef, useState, useCallback } from 'react'; +import api from '../api'; +import { getSocket } from '../socket'; + +interface ActivityEntry { + id: string; + board_id: string; + user_id: string | null; + actor_name: string | null; + actor_email: string | null; + action: string; + target_type: string | null; + target_id: string | null; + target_label: string | null; + metadata: any; + created_at: string; +} + +interface Props { + boardId: string; + onClose: () => void; +} + +const PAGE = 50; + +const ACTION_META: Record = { + 'image.added': { label: 'Image', verb: 'added', tone: 'add', icon: '🖼' }, + 'video.added': { label: 'Video', verb: 'added', tone: 'add', icon: '🎞' }, + 'pdf.added': { label: 'PDF', verb: 'added', tone: 'add', icon: '📄' }, + 'board.created': { label: 'Board', verb: 'created', tone: 'add', icon: '✦' }, + 'board.renamed': { label: 'Board', verb: 'renamed', tone: 'edit', icon: '✎' }, + 'board.deleted': { label: 'Board', verb: 'deleted', tone: 'remove', icon: '✕' }, + 'thread.created': { label: 'Comment', verb: 'started', tone: 'comment', icon: '💬' }, + 'thread.resolved': { label: 'Thread', verb: 'resolved', tone: 'edit', icon: '✓' }, + 'thread.reopened': { label: 'Thread', verb: 'reopened', tone: 'edit', icon: '↺' }, + 'comment.added': { label: 'Reply', verb: 'posted', tone: 'comment', icon: '↪' }, +}; + +const TONE_COLOR: Record = { + add: '#5fc97e', + remove: '#ff8a8a', + edit: '#7ba9ff', + comment: '#e0b75b', +}; + +function timeAgo(iso: string): string { + // SQLite returns "2026-04-28 15:50:02" — append Z so JS parses as UTC + const d = new Date(iso.includes('T') ? iso : iso.replace(' ', 'T') + 'Z'); + const diff = (Date.now() - d.getTime()) / 1000; + if (diff < 5) return 'just now'; + if (diff < 60) return `${Math.floor(diff)}s ago`; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + if (diff < 86400 * 7) return `${Math.floor(diff / 86400)}d ago`; + return d.toLocaleDateString(); +} + +function dayBucket(iso: string): string { + const d = new Date(iso.includes('T') ? iso : iso.replace(' ', 'T') + 'Z'); + const now = new Date(); + const startOfDay = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime(); + const days = Math.floor((startOfDay(now) - startOfDay(d)) / 86400000); + if (days <= 0) return 'Today'; + if (days === 1) return 'Yesterday'; + if (days < 7) return `${days} days ago`; + return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); +} + +export default function ActivityPanel({ boardId, onClose }: Props) { + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(false); + const [error, setError] = useState(''); + const [tick, setTick] = useState(0); // Force re-render every 30s for relative timestamps + const listRef = useRef(null); + + const load = useCallback(async (before: string | null = null) => { + if (before) setLoadingMore(true); else setLoading(true); + setError(''); + try { + const params = new URLSearchParams({ limit: String(PAGE) }); + if (before) params.append('before', before); + const res = await api.get(`/api/boards/${boardId}/activity?${params.toString()}`); + const newEntries: ActivityEntry[] = res.data?.activity || []; + setEntries((prev) => before ? [...prev, ...newEntries] : newEntries); + setHasMore(!!res.data?.hasMore); + } catch (e: any) { + setError(e?.response?.data?.error || 'Failed to load activity'); + } finally { + setLoading(false); + setLoadingMore(false); + } + }, [boardId]); + + useEffect(() => { + load(null); + }, [load]); + + // Live updates via Socket.IO + useEffect(() => { + const socket = getSocket(); + if (!socket) return; + const onNew = (entry: ActivityEntry) => { + if (entry.board_id !== boardId) return; + setEntries((prev) => { + if (prev.some((e) => e.id === entry.id)) return prev; + return [entry, ...prev]; + }); + }; + socket.on('activity:new', onNew); + return () => { socket.off('activity:new', onNew); }; + }, [boardId]); + + // Tick every 30s so relative timestamps stay fresh + useEffect(() => { + const t = setInterval(() => setTick((n) => n + 1), 30000); + return () => clearInterval(t); + }, []); + + // Group by day bucket + const groups: { label: string; items: ActivityEntry[] }[] = []; + for (const e of entries) { + const bucket = dayBucket(e.created_at); + const last = groups[groups.length - 1]; + if (last && last.label === bucket) last.items.push(e); + else groups.push({ label: bucket, items: [e] }); + } + + const oldestTs = entries.length ? entries[entries.length - 1].created_at : null; + + // Quiet "tick" warning + void tick; + + return ( +
+ {/* Header */} +
+
+
~
+
+
Activity
+
Per-board log · live
+
+
+ +
+ + {/* List */} +
+ {loading ? ( +
+ Loading activity… +
+ ) : error ? ( +
{error}
+ ) : entries.length === 0 ? ( +
+ No activity yet. +
+ Uploads, board renames, threads and comments will show up here. +
+
+ ) : groups.map((g) => ( +
+
{g.label}
+ {g.items.map((e) => ( + + ))} +
+ ))} + + {hasMore && !loading && ( +
+ +
+ )} +
+
+ ); +} + +function Row({ entry }: { entry: ActivityEntry }) { + const meta = ACTION_META[entry.action] || { label: entry.action, verb: '', tone: 'edit' as const, icon: '•' }; + const accent = TONE_COLOR[meta.tone]; + return ( +
+
{meta.icon}
+
+
+ {entry.actor_name || 'Someone'} + {meta.verb} + {meta.label.toLowerCase()} + {entry.target_label && ( + <> + · + {entry.target_label} + + )} +
+
{timeAgo(entry.created_at)}
+
+
+ ); +} diff --git a/frontend/src/components/Toolbar.tsx b/frontend/src/components/Toolbar.tsx index cc4d704..e2ebf73 100644 --- a/frontend/src/components/Toolbar.tsx +++ b/frontend/src/components/Toolbar.tsx @@ -30,6 +30,8 @@ interface ToolbarProps { onToggleLayers?: () => void; showLayers?: boolean; onToggleHelp?: () => void; + onToggleActivity?: () => void; + showActivity?: boolean; onExport?: () => void; onRefreshPreview?: () => void; previewRefreshing?: boolean; @@ -164,6 +166,8 @@ export default function Toolbar({ onToggleLayers, showLayers, onToggleHelp, + onToggleActivity, + showActivity, onExport, onRefreshPreview, previewRefreshing, @@ -317,6 +321,17 @@ export default function Toolbar({ )} + {/* Activity log */} + {onToggleActivity && ( + + + + + + + )} + {/* Help / shortcuts */} {onToggleHelp && ( diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx index 8fc98d1..8aeb78d 100644 --- a/frontend/src/pages/Editor.tsx +++ b/frontend/src/pages/Editor.tsx @@ -25,6 +25,7 @@ import { getStickyWidthForSize } from '../canvas/stickyPresets'; import VideoControls from '../components/VideoControls'; import ShortcutsHelp from '../components/ShortcutsHelp'; import Minimap from '../components/Minimap'; +import ActivityPanel from '../components/ActivityPanel'; import UploadPanel from '../components/UploadPanel'; import ExportDialog from '../components/ExportDialog'; import FeedbackPanel from '../components/feedback/FeedbackPanel'; @@ -115,6 +116,7 @@ export default function Editor({ isPublicView }: EditorProps) { const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); const [toasts, setToasts] = useState<{ id: string; text: string }[]>([]); const [showLayers, setShowLayers] = useState(false); + const [showActivity, setShowActivity] = useState(false); const [showGrid, setShowGrid] = useState(true); const [showHelp, setShowHelp] = useState(false); const [showExport, setShowExport] = useState(false); @@ -1090,6 +1092,8 @@ export default function Editor({ isPublicView }: EditorProps) { followingUserId={followingUserId} onToggleLayers={() => setShowLayers((v) => !v)} showLayers={showLayers} + onToggleActivity={() => setShowActivity((v) => !v)} + showActivity={showActivity} onToggleHelp={() => setShowHelp((v) => !v)} onExport={() => setShowExport(true)} onRefreshPreview={readOnly || isPublicView ? undefined : handleRefreshPreview} @@ -1539,6 +1543,11 @@ export default function Editor({ isPublicView }: EditorProps) { /> )} + {/* Activity log */} + {showActivity && resolvedBoardId && ( + setShowActivity(false)} /> + )} + {/* Layer panel */} {showLayers && (