From 36d5891d85bd6e1a7487e9aee9a89545fff4b703 Mon Sep 17 00:00:00 2001 From: Nyk <0xnykcd@googlemail.com> Date: Wed, 4 Mar 2026 23:37:45 +0700 Subject: [PATCH] feat: add validated @mentions for tasks and comments --- src/app/api/mentions/route.ts | 50 ++++++ src/app/api/tasks/[id]/comments/route.ts | 22 ++- src/app/api/tasks/[id]/route.ts | 31 ++++ src/app/api/tasks/route.ts | 23 +++ src/components/panels/task-board-panel.tsx | 178 ++++++++++++++++++++- src/lib/__tests__/db-helpers.test.ts | 8 + src/lib/db.ts | 11 +- src/lib/mentions.ts | 144 +++++++++++++++++ tests/mentions.spec.ts | 86 ++++++++++ 9 files changed, 530 insertions(+), 23 deletions(-) create mode 100644 src/app/api/mentions/route.ts create mode 100644 src/lib/mentions.ts create mode 100644 tests/mentions.spec.ts diff --git a/src/app/api/mentions/route.ts b/src/app/api/mentions/route.ts new file mode 100644 index 0000000..288850b --- /dev/null +++ b/src/app/api/mentions/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireRole } from '@/lib/auth' +import { getDatabase } from '@/lib/db' +import { getMentionTargets } from '@/lib/mentions' +import { logger } from '@/lib/logger' + +/** + * GET /api/mentions - autocomplete source for @mentions (users + agents) + * Query: q?, limit?, type? + */ +export async function GET(request: NextRequest) { + const auth = requireRole(request, 'viewer') + if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) + + try { + const db = getDatabase() + const workspaceId = auth.user.workspace_id ?? 1 + const { searchParams } = new URL(request.url) + + const q = String(searchParams.get('q') || '').trim().toLowerCase() + const typeFilter = String(searchParams.get('type') || '').trim().toLowerCase() + const limitRaw = Number.parseInt(searchParams.get('limit') || '25', 10) + const limit = Math.max(1, Math.min(Number.isFinite(limitRaw) ? limitRaw : 25, 200)) + + let targets = getMentionTargets(db, workspaceId) + + if (typeFilter === 'user' || typeFilter === 'agent') { + targets = targets.filter((target) => target.type === typeFilter) + } + + if (q) { + targets = targets.filter((target) => ( + target.handle.includes(q) || + target.recipient.toLowerCase().includes(q) || + target.display.toLowerCase().includes(q) + )) + } + + targets = targets.slice(0, limit) + + return NextResponse.json({ + mentions: targets, + total: targets.length, + q, + }) + } catch (error) { + logger.error({ err: error }, 'GET /api/mentions error') + return NextResponse.json({ error: 'Failed to fetch mention targets' }, { status: 500 }) + } +} diff --git a/src/app/api/tasks/[id]/comments/route.ts b/src/app/api/tasks/[id]/comments/route.ts index ff8a275..72d19ff 100644 --- a/src/app/api/tasks/[id]/comments/route.ts +++ b/src/app/api/tasks/[id]/comments/route.ts @@ -4,6 +4,7 @@ import { requireRole } from '@/lib/auth'; import { validateBody, createCommentSchema } from '@/lib/validation'; import { mutationLimiter } from '@/lib/rate-limit'; import { logger } from '@/lib/logger'; +import { resolveMentionRecipients } from '@/lib/mentions'; /** * GET /api/tasks/[id]/comments - Get all comments for a task @@ -128,8 +129,13 @@ export async function POST( } } - // Parse @mentions from content - const mentions = db_helpers.parseMentions(content); + const mentionResolution = resolveMentionRecipients(content, db, workspaceId); + if (mentionResolution.unresolved.length > 0) { + return NextResponse.json({ + error: `Unknown mentions: ${mentionResolution.unresolved.map((m) => `@${m}`).join(', ')}`, + missing_mentions: mentionResolution.unresolved + }, { status: 400 }); + } const now = Math.floor(Date.now() / 1000); @@ -145,7 +151,7 @@ export async function POST( content, now, parent_id || null, - mentions.length > 0 ? JSON.stringify(mentions) : null, + mentionResolution.tokens.length > 0 ? JSON.stringify(mentionResolution.tokens) : null, workspaceId ); @@ -166,7 +172,7 @@ export async function POST( task_id: taskId, task_title: task.title, parent_id, - mentions, + mentions: mentionResolution.tokens, content_preview: content.substring(0, 100) }, workspaceId @@ -174,9 +180,9 @@ export async function POST( // Ensure subscriptions for author, mentions, and assignee db_helpers.ensureTaskSubscription(taskId, author, workspaceId); - const uniqueMentions = Array.from(new Set(mentions)); - uniqueMentions.forEach((mentionedAgent) => { - db_helpers.ensureTaskSubscription(taskId, mentionedAgent, workspaceId); + const mentionRecipients = mentionResolution.recipients; + mentionRecipients.forEach((mentionedRecipient) => { + db_helpers.ensureTaskSubscription(taskId, mentionedRecipient, workspaceId); }); if (task.assigned_to) { db_helpers.ensureTaskSubscription(taskId, task.assigned_to, workspaceId); @@ -185,7 +191,7 @@ export async function POST( // Notify subscribers const subscribers = new Set(db_helpers.getTaskSubscribers(taskId, workspaceId)); subscribers.delete(author); - const mentionSet = new Set(uniqueMentions); + const mentionSet = new Set(mentionRecipients); for (const subscriber of subscribers) { const isMention = mentionSet.has(subscriber); diff --git a/src/app/api/tasks/[id]/route.ts b/src/app/api/tasks/[id]/route.ts index 7bd79c3..70fd2f9 100644 --- a/src/app/api/tasks/[id]/route.ts +++ b/src/app/api/tasks/[id]/route.ts @@ -5,6 +5,7 @@ import { requireRole } from '@/lib/auth'; import { mutationLimiter } from '@/lib/rate-limit'; import { logger } from '@/lib/logger'; import { validateBody, updateTaskSchema } from '@/lib/validation'; +import { resolveMentionRecipients } from '@/lib/mentions'; function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined { if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined @@ -126,6 +127,17 @@ export async function PUT( } = body; const now = Math.floor(Date.now() / 1000); + const descriptionMentionResolution = description !== undefined + ? resolveMentionRecipients(description || '', db, workspaceId) + : null; + if (descriptionMentionResolution && descriptionMentionResolution.unresolved.length > 0) { + return NextResponse.json({ + error: `Unknown mentions: ${descriptionMentionResolution.unresolved.map((m) => `@${m}`).join(', ')}`, + missing_mentions: descriptionMentionResolution.unresolved + }, { status: 400 }); + } + + const previousDescriptionMentionRecipients = resolveMentionRecipients(currentTask.description || '', db, workspaceId).recipients; // Build dynamic update query const fieldsToUpdate = []; @@ -274,6 +286,25 @@ export async function PUT( if (project_id !== undefined && project_id !== currentTask.project_id) { changes.push(`project: ${currentTask.project_id || 'none'} → ${project_id}`); } + + if (descriptionMentionResolution) { + const newMentionRecipients = new Set(descriptionMentionResolution.recipients); + const previousRecipients = new Set(previousDescriptionMentionRecipients); + for (const recipient of newMentionRecipients) { + if (previousRecipients.has(recipient)) continue; + db_helpers.ensureTaskSubscription(taskId, recipient, workspaceId); + if (recipient === auth.user.username) continue; + db_helpers.createNotification( + recipient, + 'mention', + 'You were mentioned in a task description', + `${auth.user.username} mentioned you in task "${title || currentTask.title}"`, + 'task', + taskId, + workspaceId + ); + } + } // Log activity if there were meaningful changes if (changes.length > 0) { diff --git a/src/app/api/tasks/route.ts b/src/app/api/tasks/route.ts index e8f20cf..bdd1626 100644 --- a/src/app/api/tasks/route.ts +++ b/src/app/api/tasks/route.ts @@ -5,6 +5,7 @@ import { requireRole } from '@/lib/auth'; import { mutationLimiter } from '@/lib/rate-limit'; import { logger } from '@/lib/logger'; import { validateBody, createTaskSchema, bulkUpdateTaskStatusSchema } from '@/lib/validation'; +import { resolveMentionRecipients } from '@/lib/mentions'; function formatTicketRef(prefix?: string | null, num?: number | null): string | undefined { if (!prefix || typeof num !== 'number' || !Number.isFinite(num) || num <= 0) return undefined @@ -180,6 +181,14 @@ export async function POST(request: NextRequest) { } const now = Math.floor(Date.now() / 1000); + const mentionResolution = resolveMentionRecipients(description || '', db, workspaceId); + if (mentionResolution.unresolved.length > 0) { + return NextResponse.json({ + error: `Unknown mentions: ${mentionResolution.unresolved.map((m) => `@${m}`).join(', ')}`, + missing_mentions: mentionResolution.unresolved + }, { status: 400 }); + } + const createTaskTx = db.transaction(() => { const resolvedProjectId = resolveProjectId(db, workspaceId, project_id) db.prepare(` @@ -234,6 +243,20 @@ export async function POST(request: NextRequest) { db_helpers.ensureTaskSubscription(taskId, created_by, workspaceId) } + for (const recipient of mentionResolution.recipients) { + db_helpers.ensureTaskSubscription(taskId, recipient, workspaceId); + if (recipient === created_by) continue; + db_helpers.createNotification( + recipient, + 'mention', + 'You were mentioned in a task description', + `${created_by} mentioned you in task "${title}"`, + 'task', + taskId, + workspaceId + ); + } + // Create notification if assigned if (assigned_to) { db_helpers.ensureTaskSubscription(taskId, assigned_to, workspaceId) diff --git a/src/components/panels/task-board-panel.tsx b/src/components/panels/task-board-panel.tsx index 6401e2c..e7dc395 100644 --- a/src/components/panels/task-board-panel.tsx +++ b/src/components/panels/task-board-panel.tsx @@ -69,6 +69,14 @@ interface Project { status: 'active' | 'archived' } +interface MentionOption { + handle: string + recipient: string + type: 'user' | 'agent' + display: string + role?: string +} + const statusColumns = [ { key: 'inbox', title: 'Inbox', color: 'bg-secondary text-foreground' }, { key: 'assigned', title: 'Assigned', color: 'bg-blue-500/20 text-blue-400' }, @@ -85,6 +93,155 @@ const priorityColors: Record = { critical: 'border-red-500', } +function useMentionTargets() { + const [mentionTargets, setMentionTargets] = useState([]) + + useEffect(() => { + let cancelled = false + const run = async () => { + try { + const response = await fetch('/api/mentions?limit=200') + if (!response.ok) return + const data = await response.json() + if (!cancelled) setMentionTargets(data.mentions || []) + } catch { + // mention autocomplete is non-critical + } + } + run() + return () => { cancelled = true } + }, []) + + return mentionTargets +} + +function MentionTextarea({ + id, + value, + onChange, + rows = 3, + placeholder, + className, + mentionTargets, +}: { + id?: string + value: string + onChange: (next: string) => void + rows?: number + placeholder?: string + className?: string + mentionTargets: MentionOption[] +}) { + const textareaRef = useRef(null) + const [open, setOpen] = useState(false) + const [activeIndex, setActiveIndex] = useState(0) + const [query, setQuery] = useState('') + const [range, setRange] = useState<{ start: number; end: number } | null>(null) + + const filtered = mentionTargets + .filter((target) => { + if (!query) return true + const q = query.toLowerCase() + return target.handle.includes(q) || target.display.toLowerCase().includes(q) + }) + .slice(0, 8) + + const detectMentionQuery = (nextValue: string, caret: number) => { + const left = nextValue.slice(0, caret) + const match = left.match(/(?:^|[^\w.-])@([A-Za-z0-9._-]{0,63})$/) + if (!match) { + setOpen(false) + setQuery('') + setRange(null) + return + } + const matched = match[1] || '' + const start = caret - matched.length - 1 + setQuery(matched) + setRange({ start, end: caret }) + setActiveIndex(0) + setOpen(true) + } + + const insertMention = (option: MentionOption) => { + if (!range) return + const next = `${value.slice(0, range.start)}@${option.handle} ${value.slice(range.end)}` + onChange(next) + setOpen(false) + setQuery('') + const cursor = range.start + option.handle.length + 2 + requestAnimationFrame(() => { + const node = textareaRef.current + if (!node) return + node.focus() + node.setSelectionRange(cursor, cursor) + }) + } + + return ( +
+