From a3794c38620dafd1ef4e1a759af4fa1574008668 Mon Sep 17 00:00:00 2001 From: ANCIENTINSANE Date: Sat, 7 Feb 2026 02:20:10 +0530 Subject: [PATCH] feat: fix image uploads, user mentions, and workflow routing --- package-lock.json | 14 +++ package.json | 1 + src/app/globals.css | 5 + src/components/editor/editor-toolbar.tsx | 23 +++- src/components/editor/rich-text-editor.tsx | 110 +++++++++++++++++- src/features/attachments/api/route.ts | 17 ++- src/features/attachments/server/route.ts | 69 +++++------ .../comments/components/mention-input.tsx | 7 +- src/features/comments/server/route.ts | 36 +++--- src/features/comments/utils/mention-utils.ts | 54 ++++++--- .../components/notification-item.tsx | 9 ++ src/features/notifications/types.ts | 1 + .../sprints/server/work-items-route.ts | 26 ++++- .../tasks/components/task-description.tsx | 45 ++++++- .../tasks/components/task-preview-modal.tsx | 89 +++++++++----- src/features/tasks/server/route.ts | 26 ++++- src/features/usage/server/route.ts | 96 +++++++++++++-- src/features/workflows/server/route.ts | 9 +- src/lib/mentions.ts | 64 ++++++++++ src/lib/notifications.ts | 1 + src/lib/notifications/dispatcher.ts | 6 + src/lib/notifications/events.ts | 25 ++++ src/lib/notifications/index.ts | 1 + src/lib/notifications/types.ts | 6 + 24 files changed, 593 insertions(+), 147 deletions(-) create mode 100644 src/lib/mentions.ts diff --git a/package-lock.json b/package-lock.json index f2207411..084f9788 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "@tiptap/core": "^2.11.5", "@tiptap/extension-color": "^2.11.5", "@tiptap/extension-highlight": "^2.11.5", + "@tiptap/extension-image": "^2.27.2", "@tiptap/extension-link": "^2.11.5", "@tiptap/extension-mention": "^2.11.5", "@tiptap/extension-placeholder": "^2.11.5", @@ -4478,6 +4479,19 @@ "@tiptap/pm": "^2.7.0" } }, + "node_modules/@tiptap/extension-image": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-2.27.2.tgz", + "integrity": "sha512-5zL/BY41FIt72azVrCrv3n+2YJ/JyO8wxCcA4Dk1eXIobcgVyIdo4rG39gCqIOiqziAsqnqoj12QHTBtHsJ6mQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, "node_modules/@tiptap/extension-italic": { "version": "2.27.2", "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.27.2.tgz", diff --git a/package.json b/package.json index 29b085db..a98bc7bc 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@tiptap/core": "^2.11.5", "@tiptap/extension-color": "^2.11.5", "@tiptap/extension-highlight": "^2.11.5", + "@tiptap/extension-image": "^2.27.2", "@tiptap/extension-link": "^2.11.5", "@tiptap/extension-mention": "^2.11.5", "@tiptap/extension-placeholder": "^2.11.5", diff --git a/src/app/globals.css b/src/app/globals.css index 21c82500..6d948715 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -321,6 +321,11 @@ body { @apply border-t border-border my-6; } +/* Inline image styles */ +.inline-desc-image { + @apply max-w-full rounded-lg my-2.5; +} + /* Mention styles */ .rich-text-editor .ProseMirror .mention { @apply bg-primary/10 text-primary rounded px-1 py-0.5 font-medium; diff --git a/src/components/editor/editor-toolbar.tsx b/src/components/editor/editor-toolbar.tsx index 12e378a8..9f7a63bc 100644 --- a/src/components/editor/editor-toolbar.tsx +++ b/src/components/editor/editor-toolbar.tsx @@ -23,6 +23,7 @@ import { ToggleLeft, Code2, Minus, + ImageIcon, } from "lucide-react"; import { useState, useCallback } from "react"; @@ -46,6 +47,7 @@ import { Separator } from "@/components/ui/separator"; interface EditorToolbarProps { editor: Editor; onSetLink: () => void; + onImageUpload?: () => void; } const FONT_COLORS = [ @@ -110,7 +112,7 @@ const ToolbarButton = ({ ); -export const EditorToolbar = ({ editor, onSetLink }: EditorToolbarProps) => { +export const EditorToolbar = ({ editor, onSetLink, onImageUpload }: EditorToolbarProps) => { const [showInsertMenu, setShowInsertMenu] = useState(false); const [showColorMenu, setShowColorMenu] = useState(false); const [showHighlightMenu, setShowHighlightMenu] = useState(false); @@ -180,6 +182,15 @@ export const EditorToolbar = ({ editor, onSetLink }: EditorToolbarProps) => { Divider + {onImageUpload && ( + <> + + + + Image + + + )} @@ -189,11 +200,11 @@ export const EditorToolbar = ({ editor, onSetLink }: EditorToolbarProps) => { diff --git a/src/components/editor/rich-text-editor.tsx b/src/components/editor/rich-text-editor.tsx index 1e488239..d600d1e8 100644 --- a/src/components/editor/rich-text-editor.tsx +++ b/src/components/editor/rich-text-editor.tsx @@ -14,6 +14,7 @@ import TableHeader from "@tiptap/extension-table-header"; import TableCell from "@tiptap/extension-table-cell"; import TaskList from "@tiptap/extension-task-list"; import TaskItem from "@tiptap/extension-task-item"; +import Image from "@tiptap/extension-image"; import { useCallback, useEffect, forwardRef, useImperativeHandle, useRef } from "react"; import { cn } from "@/lib/utils"; @@ -33,6 +34,7 @@ export interface RichTextEditorProps { minHeight?: string; showToolbar?: boolean; autofocus?: boolean; + onImageUpload?: (file: File) => Promise; } export interface RichTextEditorRef { @@ -56,11 +58,13 @@ export const RichTextEditor = forwardRef minHeight = "120px", showToolbar = true, autofocus = false, + onImageUpload, }, ref ) => { const isFocusedRef = useRef(false); const lastContentRef = useRef(content); + const imageInputRef = useRef(null); const editor = useEditor({ extensions: [ @@ -128,6 +132,13 @@ export const RichTextEditor = forwardRef class: "flex items-start gap-2", }, }), + Image.configure({ + inline: false, + allowBase64: false, + HTMLAttributes: { + class: "inline-desc-image", + }, + }), ...(workspaceId ? [createMentionExtension()] : []), ...(workspaceId && projectId ? [createSlashCommandExtension(workspaceId, projectId)] @@ -149,6 +160,66 @@ export const RichTextEditor = forwardRef "[&_.is-editor-empty]:before:content-[attr(data-placeholder)] [&_.is-editor-empty]:before:text-muted-foreground [&_.is-editor-empty]:before:float-left [&_.is-editor-empty]:before:pointer-events-none [&_.is-editor-empty]:before:h-0" ), }, + handlePaste: (view, event) => { + // Handle pasted images + const items = event.clipboardData?.items; + if (!items || !onImageUpload) return false; + + for (const item of items) { + if (item.type.startsWith("image/")) { + event.preventDefault(); + const file = item.getAsFile(); + if (file) { + onImageUpload(file) + .then((url) => { + if (url) { + // Use the current view state, not the captured one + const { state, dispatch } = view; + const imageNode = state.schema.nodes.image; + if (imageNode) { + const node = imageNode.create({ src: url }); + const transaction = state.tr.replaceSelectionWith(node); + dispatch(transaction); + } + } + }) + .catch((err) => { + console.error("[RichTextEditor] Image upload failed:", err); + }); + } + return true; + } + } + return false; + }, + handleDrop: (view, event) => { + // Handle dropped images + const files = event.dataTransfer?.files; + if (!files || !onImageUpload) return false; + + for (const file of files) { + if (file.type.startsWith("image/")) { + event.preventDefault(); + onImageUpload(file) + .then((url) => { + if (url) { + const { state, dispatch } = view; + const imageNode = state.schema.nodes.image; + if (imageNode) { + const node = imageNode.create({ src: url }); + const transaction = state.tr.replaceSelectionWith(node); + dispatch(transaction); + } + } + }) + .catch((err) => { + console.error("[RichTextEditor] Image upload failed:", err); + }); + return true; + } + } + return false; + }, }, onUpdate: ({ editor: editorInstance }) => { lastContentRef.current = editorInstance.getHTML(); @@ -222,6 +293,30 @@ export const RichTextEditor = forwardRef .run(); }, [editor]); + const handleImageUploadClick = useCallback(() => { + imageInputRef.current?.click(); + }, []); + + const handleImageInputChange = useCallback( + async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file || !onImageUpload || !editor) return; + + try { + const url = await onImageUpload(file); + if (url) { + editor.chain().focus().setImage({ src: url }).run(); + } + } catch (error) { + console.error("Failed to upload image:", error); + } + + // Reset the input so the same file can be selected again + event.target.value = ""; + }, + [editor, onImageUpload] + ); + if (!editor) { return null; } @@ -229,9 +324,22 @@ export const RichTextEditor = forwardRef return (
{showToolbar && editable && ( - + )} + {/* Hidden file input for image upload */} + + {/* Bubble Menu for quick formatting */} {editable && ( ({ - ...attachment, - url: storage.getFileView(ATTACHMENTS_BUCKET_ID, attachment.fileId).toString(), - })) - ); + // Add URLs to attachments - use proxy URL for authentication support + const attachmentsWithUrls = attachments.map((attachment) => ({ + ...attachment, + url: `/api/attachments/${attachment.$id}/preview?workspaceId=${workspaceId}`, + })); return c.json({ data: attachmentsWithUrls }); } @@ -113,8 +110,8 @@ const app = new Hono() uploadedBy: user.$id, }); - // Add URL to response - const url = storage.getFileView(ATTACHMENTS_BUCKET_ID, attachment.fileId).toString(); + // Add URL to response - Use proxy URL for authentication support + const url = `/api/attachments/${attachment.$id}/preview?workspaceId=${workspaceId}`; // Log storage usage for billing - every byte is billable logStorageUsage({ diff --git a/src/features/attachments/server/route.ts b/src/features/attachments/server/route.ts index 0e5c76de..b05f6dfa 100644 --- a/src/features/attachments/server/route.ts +++ b/src/features/attachments/server/route.ts @@ -49,31 +49,26 @@ export const createAttachment = async (data: { } ); - // Send notifications (non-blocking) + // Send notifications (non-blocking) using the new event-driven system + // This automatically deduplicates recipients and ignores the triggerer try { + const { dispatchWorkitemEvent, createAttachmentAddedEvent } = await import("@/lib/notifications"); const task = await databases.getDocument(DATABASE_ID, TASKS_ID, data.taskId); const uploaderName = data.uploaderName || "Someone"; - // Notify assignees - notifyTaskAssignees({ - databases, + const event = createAttachmentAddedEvent( task, - triggeredByUserId: data.uploadedBy, - triggeredByName: uploaderName, - notificationType: "task_attachment_added", - workspaceId: data.workspaceId, - }).catch(() => {}); - - // Notify workspace admins - notifyWorkspaceAdmins({ - databases, - task, - triggeredByUserId: data.uploadedBy, - triggeredByName: uploaderName, - notificationType: "task_attachment_added", - workspaceId: data.workspaceId, - }).catch(() => {}); - } catch { + data.uploadedBy, + uploaderName, + attachment.$id, + attachment.name + ); + + dispatchWorkitemEvent(event).catch((err) => { + console.error("[Attachments] Failed to dispatch attachment event:", err); + }); + } catch (err) { + console.error("[Attachments] Error preparing notifications:", err); // Silently fail - notifications are non-critical } @@ -117,32 +112,26 @@ export const deleteAttachment = async ( attachmentId ); - // Send notifications (non-blocking) + // Send notifications (non-blocking) using the new event-driven system if (deletedBy && taskId) { try { + const { dispatchWorkitemEvent, createAttachmentDeletedEvent } = await import("@/lib/notifications"); const task = await databases.getDocument(DATABASE_ID, TASKS_ID, taskId); const userName = deleterName || "Someone"; - // Notify assignees - notifyTaskAssignees({ - databases, - task, - triggeredByUserId: deletedBy, - triggeredByName: userName, - notificationType: "task_attachment_deleted", - workspaceId, - }).catch(() => {}); - - // Notify workspace admins - notifyWorkspaceAdmins({ - databases, + const event = createAttachmentDeletedEvent( task, - triggeredByUserId: deletedBy, - triggeredByName: userName, - notificationType: "task_attachment_deleted", - workspaceId, - }).catch(() => {}); - } catch { + deletedBy, + userName, + attachmentId, + attachment.name + ); + + dispatchWorkitemEvent(event).catch((err) => { + console.error("[Attachments] Failed to dispatch attachment delete event:", err); + }); + } catch (err) { + console.error("[Attachments] Error preparing notifications:", err); // Silently fail - notifications are non-critical } } diff --git a/src/features/comments/components/mention-input.tsx b/src/features/comments/components/mention-input.tsx index fd31c342..2062cccc 100644 --- a/src/features/comments/components/mention-input.tsx +++ b/src/features/comments/components/mention-input.tsx @@ -83,7 +83,10 @@ export const MentionInput = ({ const afterMention = value.substring( textareaRef.current?.selectionStart || mentionStartIndex + mentionQuery.length + 1 ); - const mentionText = `@${member.name || member.email} `; + // Include userId in parseable format: @Name[userId] + // The format @[userId] is parsed by extractMentions() for notification dispatch + const displayName = member.name || member.email || "User"; + const mentionText = `@${displayName}[${member.userId}]`; const newValue = beforeMention + mentionText + afterMention; onChange(newValue); @@ -106,7 +109,7 @@ export const MentionInput = ({ const handleInputChange = (e: React.ChangeEvent) => { const newValue = e.target.value; const cursorPos = e.target.selectionStart; - + onChange(newValue); // Check if we should show mention dropdown diff --git a/src/features/comments/server/route.ts b/src/features/comments/server/route.ts index 3b7a9180..ae50b61f 100644 --- a/src/features/comments/server/route.ts +++ b/src/features/comments/server/route.ts @@ -2,23 +2,9 @@ import { createAdminClient } from "@/lib/appwrite"; import { DATABASE_ID, COMMENTS_ID, TASKS_ID } from "@/config"; import { Comment, PopulatedComment, CommentAuthor } from "../types"; import { Query, ID } from "node-appwrite"; -import { dispatchWorkitemEvent, createCommentAddedEvent } from "@/lib/notifications"; +import { dispatchWorkitemEvent, createCommentAddedEvent, createMentionEvent } from "@/lib/notifications"; import { Task } from "@/features/tasks/types"; - -/** - * Extract @mentioned user IDs from comment content - * Matches patterns like @userId or @[userId] - */ -function extractMentionedUserIds(content: string): string[] { - // Match @mentions in format @userId or @[userId] - const mentionRegex = /@\[?([a-zA-Z0-9_-]+)\]?/g; - const mentions: string[] = []; - let match; - while ((match = mentionRegex.exec(content)) !== null) { - mentions.push(match[1]); - } - return mentions; -} +import { extractMentions, extractSnippet } from "@/lib/mentions"; // Get all comments for a task, optionally filtering by parentId export const getComments = async ( @@ -100,7 +86,7 @@ export const createComment = async (data: { } ); - // Emit comment added event (non-blocking) + // Emit comment added event and mention events (non-blocking) try { const task = await databases.getDocument( DATABASE_ID, @@ -108,8 +94,15 @@ export const createComment = async (data: { data.taskId ); const authorName = data.authorName || "Someone"; - const mentionedUserIds = extractMentionedUserIds(data.content); + const mentionedUserIds = extractMentions(data.content); + const snippet = extractSnippet(data.content); + + // console.log("[Comments] Creating comment, content:", data.content.slice(0, 200)); + // console.log("[Comments] Extracted mentions:", mentionedUserIds); + // Dispatch comment added event with all mentioned users + // Note: mentionedUserIds are passed to the event, and the dispatcher + // handles creating notifications for mentioned users - no separate dispatch needed const event = createCommentAddedEvent( task, data.authorId, @@ -117,8 +110,11 @@ export const createComment = async (data: { comment.$id, mentionedUserIds.length > 0 ? mentionedUserIds : undefined ); - dispatchWorkitemEvent(event).catch(() => { }); - } catch { + dispatchWorkitemEvent(event).catch((err) => { + console.error("[Comments] Failed to dispatch comment event:", err); + }); + } catch (err) { + console.error("[Comments] Error dispatching notifications:", err); // Silently fail - notifications are non-critical } diff --git a/src/features/comments/utils/mention-utils.ts b/src/features/comments/utils/mention-utils.ts index 78608adc..29f2fca9 100644 --- a/src/features/comments/utils/mention-utils.ts +++ b/src/features/comments/utils/mention-utils.ts @@ -2,21 +2,30 @@ import { Member } from "@/features/members/types"; /** * Extracts mentioned user IDs from comment content - * Mentions are in format @Username or @email@domain.com + * Mentions are in format @Username, @email@domain.com, or @Username[userId] */ export function extractMentions( content: string, members: Member[] ): string[] { const mentionedIds: string[] = []; - // Match @mentions (words or emails after @) - const mentionRegex = /@([\w\s]+?)(?=\s|$|@)|@([\w.-]+@[\w.-]+)/g; + + // Match TipTap format: @name[userId] - extract userId directly + const tiptapMentionRegex = /@[\w\s.-]+?\[([a-zA-Z0-9]+)\]/g; let match; - while ((match = mentionRegex.exec(content)) !== null) { - const mentionName = (match[1] || match[2])?.trim(); + while ((match = tiptapMentionRegex.exec(content)) !== null) { + const userId = match[1]; + if (userId && !mentionedIds.includes(userId)) { + mentionedIds.push(userId); + } + } + + // Also check plain @mentions (for backwards compatibility) + const plainMentionRegex = /@([\w\s]+?)(?=\s|$|@)/g; + while ((match = plainMentionRegex.exec(content)) !== null) { + const mentionName = match[1]?.trim(); if (mentionName) { - // Find member by name or email const member = members.find( (m) => m.name?.toLowerCase() === mentionName.toLowerCase() || @@ -33,6 +42,7 @@ export function extractMentions( /** * Parses content and returns array of text parts and mention parts + * Handles both TipTap format @name[id] and plain @name format */ export type ContentPart = | { type: "text"; content: string } @@ -43,8 +53,10 @@ export function parseContentWithMentions( members?: Member[] ): ContentPart[] { const parts: ContentPart[] = []; - // Match @mentions followed by a name (stopping at next @ or newline) - const mentionRegex = /@([\w\s.-]+?)(?=\s@|\s{2}|$|\n)/g; + + // Match @name[userId] format OR plain @name format + // Group 1: name, Group 2: optional userId (inside brackets) + const mentionRegex = /@([\w.-]+)(?:\[([a-zA-Z0-9]+)\])?/g; let lastIndex = 0; let match; @@ -58,18 +70,25 @@ export function parseContentWithMentions( } const mentionName = match[1].trim(); - - // Find member to get userId - const member = members?.find( - (m) => - m.name?.toLowerCase() === mentionName.toLowerCase() || - m.email?.toLowerCase() === mentionName.toLowerCase() - ); + const mentionUserId = match[2]; // userId from brackets if present + + // Try to find member by userId first, then by name + let member = null; + if (mentionUserId) { + member = members?.find((m) => m.userId === mentionUserId); + } + if (!member) { + member = members?.find( + (m) => + m.name?.toLowerCase() === mentionName.toLowerCase() || + m.email?.toLowerCase() === mentionName.toLowerCase() + ); + } parts.push({ type: "mention", - name: mentionName, - userId: member?.userId, + name: member?.name || mentionName, // Use resolved name if available + userId: mentionUserId || member?.userId, }); lastIndex = match.index + match[0].length; @@ -90,3 +109,4 @@ export function parseContentWithMentions( return parts; } + diff --git a/src/features/notifications/components/notification-item.tsx b/src/features/notifications/components/notification-item.tsx index 36db9e8b..9041f3b1 100644 --- a/src/features/notifications/components/notification-item.tsx +++ b/src/features/notifications/components/notification-item.tsx @@ -12,6 +12,7 @@ import { Flag, ArrowRight, User, + AtSign, } from "lucide-react"; import Link from "next/link"; @@ -87,6 +88,14 @@ const getNotificationStyle = (type: NotificationType) => { badge: "Attachment Removed", badgeVariant: "destructive" as const, }; + case NotificationType.TASK_MENTION: + return { + icon: AtSign, + color: "text-indigo-600 dark:text-indigo-400", + bgColor: "bg-indigo-500/10 dark:bg-indigo-500/20", + badge: "Mentioned", + badgeVariant: "default" as const, + }; case NotificationType.TASK_UPDATED: return { icon: FileText, diff --git a/src/features/notifications/types.ts b/src/features/notifications/types.ts index 40618af0..ff646e87 100644 --- a/src/features/notifications/types.ts +++ b/src/features/notifications/types.ts @@ -9,6 +9,7 @@ export enum NotificationType { TASK_DUE_DATE_CHANGED = "task_due_date_changed", TASK_DELETED = "task_deleted", TASK_COMMENT = "task_comment", + TASK_MENTION = "task_mention", TASK_ATTACHMENT_ADDED = "task_attachment_added", TASK_ATTACHMENT_DELETED = "task_attachment_deleted", } diff --git a/src/features/sprints/server/work-items-route.ts b/src/features/sprints/server/work-items-route.ts index ad2522ec..e975020f 100644 --- a/src/features/sprints/server/work-items-route.ts +++ b/src/features/sprints/server/work-items-route.ts @@ -16,8 +16,10 @@ import { createStatusChangedEvent, createCompletedEvent, createPriorityChangedEvent, + createMentionEvent, } from "@/lib/notifications"; import { Task } from "@/features/tasks/types"; +import { extractMentions, extractSnippet } from "@/lib/mentions"; import { createWorkItemSchema, @@ -313,7 +315,7 @@ const app = new Hono() // Batch fetch all children counts in ONE query const childrenCountMap = new Map(); const childrenByParentMap = new Map(); - + if (workItemIds.length > 0) { try { const allChildren = await databases.listDocuments( @@ -326,7 +328,7 @@ const app = new Hono() const parentId = child.parentId; if (parentId) { childrenCountMap.set(parentId, (childrenCountMap.get(parentId) || 0) + 1); - + // If includeChildren is requested, store the child data if (includeChildren) { const existing = childrenByParentMap.get(parentId) || []; @@ -748,6 +750,26 @@ const app = new Hono() } } + // Description @mention notifications + if (updates.description) { + const mentionedUserIds = extractMentions(updates.description); + const snippet = extractSnippet(updates.description); + + for (const mentionedUserId of mentionedUserIds) { + // Skip self-mention + if (mentionedUserId === user.$id) continue; + + const mentionEvent = createMentionEvent( + taskLike, + user.$id, + userName, + mentionedUserId, + snippet + ); + dispatchWorkitemEvent(mentionEvent).catch(() => { }); + } + } + return c.json({ data: updatedWorkItem }); } ) diff --git a/src/features/tasks/components/task-description.tsx b/src/features/tasks/components/task-description.tsx index d40d36e4..5647ffa8 100644 --- a/src/features/tasks/components/task-description.tsx +++ b/src/features/tasks/components/task-description.tsx @@ -1,7 +1,8 @@ "use client"; -import { useEffect } from "react"; +import { useEffect, useCallback } from "react"; import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { RichTextEditor, setMentionMembers } from "@/components/editor"; @@ -18,11 +19,11 @@ interface TaskDescriptionProps { projectId?: string; } -export const TaskDescription = ({ - task, - canEdit = true, +export const TaskDescription = ({ + task, + canEdit = true, workspaceId, - projectId + projectId }: TaskDescriptionProps) => { const { mutate: updateTask } = useUpdateTask(); const { data: members } = useGetMembers({ workspaceId: workspaceId || "" }); @@ -48,7 +49,9 @@ export const TaskDescription = ({ if (members?.documents) { setMentionMembers( members.documents.map((member) => ({ - id: member.$id, + // CRITICAL: Use userId for mention data-id, not member document $id + // This ensures notifications are routed to the correct user + id: member.userId, name: member.name || "", email: member.email, imageUrl: member.profileImageUrl, @@ -62,6 +65,35 @@ export const TaskDescription = ({ setValue(content); }; + // Handle image upload for inline images in description + const handleImageUpload = useCallback(async (file: File): Promise => { + if (!workspaceId) return null; + + try { + const formData = new FormData(); + formData.append("file", file); + formData.append("taskId", task.$id); + formData.append("workspaceId", workspaceId); + + const response = await fetch('/api/attachments/upload', { + method: 'POST', + body: formData + }); + + if (!response.ok) { + toast.error("Failed to upload image"); + return null; + } + + const data = await response.json(); + const url = data?.data?.url; + return url; + } catch (err) { + toast.error("Failed to upload image"); + return null; + } + }, [task.$id, workspaceId]); + return (
{/* Status indicator */} @@ -81,6 +113,7 @@ export const TaskDescription = ({ projectId={projectId} minHeight="100px" showToolbar={canEdit} + onImageUpload={canEdit && workspaceId ? handleImageUpload : undefined} className={cn( "border-0 bg-transparent", !canEdit && "pointer-events-none" diff --git a/src/features/tasks/components/task-preview-modal.tsx b/src/features/tasks/components/task-preview-modal.tsx index cb9c346b..ac2dbf7e 100644 --- a/src/features/tasks/components/task-preview-modal.tsx +++ b/src/features/tasks/components/task-preview-modal.tsx @@ -70,7 +70,7 @@ interface TaskPreviewContentProps { onDelete?: () => void; canEdit?: boolean; canDelete?: boolean; -} +} const TaskPreviewContent = ({ task, workspaceId, onEdit, onClose, onAttachmentPreview, onDelete, canEdit = false, canDelete = false }: TaskPreviewContentProps) => { const { mutate: updateTask } = useUpdateTask(); @@ -135,7 +135,9 @@ const TaskPreviewContent = ({ task, workspaceId, onEdit, onClose, onAttachmentPr if (members?.documents) { setMentionMembers( members.documents.map((member) => ({ - id: member.$id, + // CRITICAL: Use userId for mention data-id, not member document $id + // This ensures notifications are routed to the correct user + id: member.userId, name: member.name || "", email: member.email, imageUrl: member.profileImageUrl, @@ -169,6 +171,32 @@ const TaskPreviewContent = ({ task, workspaceId, onEdit, onClose, onAttachmentPr syncDescriptionNow(); }; + // Handle image upload for inline images in description + const handleImageUpload = useCallback(async (file: File): Promise => { + try { + const formData = new FormData(); + formData.append("file", file); + formData.append("taskId", task.$id); + formData.append("workspaceId", workspaceId); + + const response = await fetch('/api/attachments/upload', { + method: 'POST', + body: formData + }); + + if (!response.ok) { + toast.error("Failed to upload image"); + return null; + } + + const data = await response.json() as { data: { url: string } }; + return data.data.url; + } catch { + toast.error("Failed to upload image"); + return null; + } + }, [task.$id, workspaceId]); + const handleCopyUrl = async () => { try { const url = typeof window !== "undefined" @@ -218,7 +246,7 @@ const TaskPreviewContent = ({ task, workspaceId, onEdit, onClose, onAttachmentPr )} handleUpdate({ status: value }) : () => {}} + onChange={canEdit ? (value) => handleUpdate({ status: value }) : () => { }} projectId={task.projectId} placeholder="Status" disabled={!canEdit} @@ -270,7 +298,7 @@ const TaskPreviewContent = ({ task, workspaceId, onEdit, onClose, onAttachmentPr onClick={handleCloseWithSync} > - +
@@ -318,14 +346,15 @@ const TaskPreviewContent = ({ task, workspaceId, onEdit, onClose, onAttachmentPr
{}} - onBlur={canEdit ? handleDescriptionBlur : () => {}} + onChange={canEdit ? setDescription : () => { }} + onBlur={canEdit ? handleDescriptionBlur : () => { }} placeholder={canEdit ? "Add a description... Use @ to mention team members, / for commands" : "No description"} editable={canEdit} workspaceId={workspaceId} projectId={task.projectId} minHeight="100px" showToolbar={canEdit} + onImageUpload={canEdit ? handleImageUpload : undefined} />
@@ -366,7 +395,7 @@ const TaskPreviewContent = ({ task, workspaceId, onEdit, onClose, onAttachmentPr handleUpdate({ status: value }) : () => {}} + onChange={canEdit ? (value) => handleUpdate({ status: value }) : () => { }} projectId={task.projectId} disabled={!canEdit} /> @@ -377,7 +406,7 @@ const TaskPreviewContent = ({ task, workspaceId, onEdit, onClose, onAttachmentPr handleUpdate({ type: value }) : () => {}} + onValueChange={canEdit ? (value) => handleUpdate({ type: value }) : () => { }} project={project} customTypes={project?.customWorkItemTypes} className="w-full bg-card border-border" @@ -390,7 +419,7 @@ const TaskPreviewContent = ({ task, workspaceId, onEdit, onClose, onAttachmentPr handleUpdate({ priority: value }) : () => {}} + onValueChange={canEdit ? (value) => handleUpdate({ priority: value }) : () => { }} customPriorities={project?.customPriorities} disabled={!canEdit} /> @@ -402,7 +431,7 @@ const TaskPreviewContent = ({ task, workspaceId, onEdit, onClose, onAttachmentPr handleUpdate({ assigneeIds: ids }) : () => {}} + onAssigneesChange={canEdit ? (ids) => handleUpdate({ assigneeIds: ids }) : () => { }} placeholder="Select assignees" disabled={!canEdit} /> @@ -413,7 +442,7 @@ const TaskPreviewContent = ({ task, workspaceId, onEdit, onClose, onAttachmentPr handleUpdate({ dueDate: date }) : () => {}} + onChange={canEdit ? (date) => handleUpdate({ dueDate: date }) : () => { }} placeholder="Set start date" className="w-full bg-card border-border" disabled={!canEdit} @@ -434,7 +463,7 @@ const TaskPreviewContent = ({ task, workspaceId, onEdit, onClose, onAttachmentPr handleUpdate({ endDate: date }) : () => {}} + onChange={canEdit ? (date) => handleUpdate({ endDate: date }) : () => { }} placeholder="Set end date" className="w-full bg-card border-border" disabled={!canEdit} @@ -458,7 +487,7 @@ const TaskPreviewContent = ({ task, workspaceId, onEdit, onClose, onAttachmentPr if (val !== task.estimatedHours) { handleUpdate({ estimatedHours: val || undefined }); } - } : () => {}} + } : () => { }} className="h-9 bg-card border-border" disabled={!canEdit} /> @@ -477,7 +506,7 @@ const TaskPreviewContent = ({ task, workspaceId, onEdit, onClose, onAttachmentPr if (val !== task.storyPoints) { handleUpdate({ storyPoints: val || undefined }); } - } : () => {}} + } : () => { }} className="h-9 bg-card border-border" disabled={!canEdit} /> @@ -487,7 +516,7 @@ const TaskPreviewContent = ({ task, workspaceId, onEdit, onClose, onAttachmentPr
handleUpdate({ flagged: checked as boolean }) : () => {}} + onCheckedChange={canEdit ? (checked) => handleUpdate({ flagged: checked as boolean }) : () => { }} id="flagged" disabled={!canEdit} /> @@ -527,16 +556,16 @@ export const TaskPreviewModalWrapper = () => { // Get workspace admin status const { isAdmin } = useCurrentMember({ workspaceId }); - + // Get project-level task permissions - const { - canEditTasksProject, + const { + canEditTasksProject, canDeleteTasksProject, - } = useProjectPermissions({ - projectId: data?.projectId || null, - workspaceId + } = useProjectPermissions({ + projectId: data?.projectId || null, + workspaceId }); - + // Effective permissions: Admin OR has project-level permission const canEditTasks = isAdmin || canEditTasksProject; const canDeleteTasks = isAdmin || canDeleteTasksProject; @@ -576,7 +605,7 @@ export const TaskPreviewModalWrapper = () => { } catch { // Navigation error handled silently } - }; + }; const handleClose = useCallback(() => { @@ -688,13 +717,13 @@ export const TaskPreviewModalWrapper = () => {
- + {previewAttachment.mimeType.startsWith("image/") ? ( // Image preview diff --git a/src/features/tasks/server/route.ts b/src/features/tasks/server/route.ts index ed4bdc0e..9a8846d6 100644 --- a/src/features/tasks/server/route.ts +++ b/src/features/tasks/server/route.ts @@ -13,7 +13,9 @@ import { createCompletedEvent, createPriorityChangedEvent, createDueDateChangedEvent, + createMentionEvent, } from "@/lib/notifications"; +import { extractMentions, extractSnippet } from "@/lib/mentions"; import { getMember } from "@/features/members/utils"; @@ -737,6 +739,28 @@ const app = new Hono() } } + // Description @mention notifications + if (description) { + const mentionedUserIds = extractMentions(description); + const snippet = extractSnippet(description); + + for (const mentionedUserId of mentionedUserIds) { + // Skip self-mention + if (mentionedUserId === user.$id) continue; + + const mentionEvent = createMentionEvent( + task, + user.$id, + userName, + mentionedUserId, + snippet + ); + dispatchWorkitemEvent(mentionEvent).catch(() => { + // Silent failure for non-critical event dispatch + }); + } + } + return c.json({ data: task }); } ) @@ -913,7 +937,7 @@ const app = new Hono() // Project permission check: verify user can edit tasks in each affected project const projectIds = [...new Set(tasksToUpdate.documents.map((t) => t.projectId))]; const { resolveUserProjectAccess, hasProjectPermission, ProjectPermissionKey } = await import("@/lib/permissions/resolveUserProjectAccess"); - + for (const projId of projectIds) { const projectAccess = await resolveUserProjectAccess(databases, user.$id, projId); if (!projectAccess.hasAccess || !hasProjectPermission(projectAccess, ProjectPermissionKey.EDIT_TASKS)) { diff --git a/src/features/usage/server/route.ts b/src/features/usage/server/route.ts index d2637549..cc71dcc9 100644 --- a/src/features/usage/server/route.ts +++ b/src/features/usage/server/route.ts @@ -51,34 +51,97 @@ async function checkAdminAccess( } /** - * Helper to check organization-level OWNER/ADMIN access + * Helper to check organization-level billing access * - * WHY: For org-level usage dashboard, we need org-level permission check, - * not workspace-level. Only org OWNER or ADMIN can view org-wide usage. + * PERFORMANCE OPTIMIZED: + * - Fast path: Check if user is OWNER first (single DB query) + * - Only resolve full permissions if not OWNER + * - In-memory cache for repeated calls within same request + * + * ACCESS GRANTED TO: + * - OWNER (always has full access) + * - Any user with BILLING_VIEW permission via department membership */ + +// In-memory cache for permission checks (cleared per-request in Hono middleware) +const permissionCache = new Map(); + async function checkOrgAdminAccess( databases: Databases, organizationId: string, userId: string ): Promise { + // Check cache first (avoids repeated DB queries in same request) + const cacheKey = `${organizationId}:${userId}`; + if (permissionCache.has(cacheKey)) { + return permissionCache.get(cacheKey)!; + } + try { - const members = await databases.listDocuments( + // FAST PATH: Check if user is OWNER first (single query) + const { OrganizationRole, OrgMemberStatus } = await import("@/features/organizations/types"); + + const membership = await databases.listDocuments( DATABASE_ID, ORGANIZATION_MEMBERS_ID, [ Query.equal("organizationId", organizationId), Query.equal("userId", userId), + Query.equal("status", OrgMemberStatus.ACTIVE), + Query.limit(1), ] ); - if (members.total === 0) { + if (membership.total === 0) { + permissionCache.set(cacheKey, false); return false; } - const member = members.documents[0]; - const hasAccess = member.role === "OWNER" || member.role === "ADMIN"; + const member = membership.documents[0]; + + // OWNER always has access - fast path complete + if (member.role === OrganizationRole.OWNER) { + permissionCache.set(cacheKey, true); + return true; + } + + // NON-OWNER: Check department permissions + const { ORG_MEMBER_DEPARTMENTS_ID, DEPARTMENT_PERMISSIONS_ID } = await import("@/config"); + const { OrgPermissionKey } = await import("@/features/org-permissions/types"); + + // Get user's department assignments + const deptAssignments = await databases.listDocuments( + DATABASE_ID, + ORG_MEMBER_DEPARTMENTS_ID, + [Query.equal("orgMemberId", member.$id)] + ); + + if (deptAssignments.total === 0) { + permissionCache.set(cacheKey, false); + return false; + } + + const departmentIds = deptAssignments.documents.map((d) => (d as unknown as { departmentId: string }).departmentId); + + // Check if any department has BILLING_VIEW permission + const { createAdminClient } = await import("@/lib/appwrite"); + const { databases: adminDb } = await createAdminClient(); + + const billingPermissions = await adminDb.listDocuments( + DATABASE_ID, + DEPARTMENT_PERMISSIONS_ID, + [ + Query.equal("departmentId", departmentIds), + Query.equal("permissionKey", OrgPermissionKey.BILLING_VIEW), + Query.limit(1), + ] + ); + + const hasAccess = billingPermissions.total > 0; + permissionCache.set(cacheKey, hasAccess); return hasAccess; } catch { + permissionCache.set(cacheKey, false); return false; } } @@ -86,14 +149,23 @@ async function checkOrgAdminAccess( /** * Helper to get all workspace IDs belonging to an organization * + * PERFORMANCE OPTIMIZED: Cached to avoid repeated queries in same request + * * WHY: For org-level usage queries, we need to aggregate usage across all * workspaces in the organization. This fetches all workspace IDs to use * in Query.equal("workspaceId", [...]) filters. */ +const workspaceCache = new Map(); + async function getOrgWorkspaceIds( databases: Databases, organizationId: string ): Promise { + // Check cache first + if (workspaceCache.has(organizationId)) { + return workspaceCache.get(organizationId)!; + } + try { const workspaces = await databases.listDocuments( DATABASE_ID, @@ -105,8 +177,10 @@ async function getOrgWorkspaceIds( ); const workspaceIds = workspaces.documents.map((ws: { $id: string }) => ws.$id); + workspaceCache.set(organizationId, workspaceIds); return workspaceIds; } catch { + workspaceCache.set(organizationId, []); return []; } } @@ -583,16 +657,16 @@ const app = new Hono() } } - // Calculate averages for storage (simple average for now) - const storageAvgBytes = storageTotalBytes / Math.max(events.total, 1); + // For storage billing: use total bytes (sum of uploads - deletes) + // NOT an average across all events - that was diluting the value incorrectly const trafficTotalGB = bytesToGB(trafficTotalBytes); - const storageAvgGB = bytesToGB(storageAvgBytes); + const storageAvgGB = bytesToGB(storageTotalBytes); const summary: UsageSummary = { period: targetPeriod, trafficTotalBytes, trafficTotalGB, - storageAvgBytes, + storageAvgBytes: storageTotalBytes, storageAvgGB, computeTotalUnits, estimatedCost: calculateCost(trafficTotalGB, storageAvgGB, computeTotalUnits), diff --git a/src/features/workflows/server/route.ts b/src/features/workflows/server/route.ts index 23571bda..3b9ccdc7 100644 --- a/src/features/workflows/server/route.ts +++ b/src/features/workflows/server/route.ts @@ -320,6 +320,13 @@ const app = new Hono() const user = c.get("user"); const { workflowId } = c.req.param(); + // Guard: Skip reserved paths that should be handled by other routes + // This prevents "allowed-transitions" from being treated as a workflow ID + const reservedPaths = ["allowed-transitions"]; + if (reservedPaths.includes(workflowId)) { + return c.json({ error: "Invalid workflow ID" }, 400); + } + const workflow = await databases.getDocument( DATABASE_ID, WORKFLOWS_ID, @@ -1426,7 +1433,7 @@ const app = new Hono() console.warn('Skipping customWorkItemType with invalid label:', type); continue; } - + const normalizedName = type.label.toLowerCase(); const existingIndex = allProjectStatuses.findIndex( s => s.name.toLowerCase() === normalizedName diff --git a/src/lib/mentions.ts b/src/lib/mentions.ts new file mode 100644 index 00000000..92af9b6a --- /dev/null +++ b/src/lib/mentions.ts @@ -0,0 +1,64 @@ +/** + * Mention Parser + * + * Extract @mentioned user IDs from text content. + * Handles both TipTap mention HTML format and plain text @username format. + */ + +/** + * Extract mentioned user IDs from text content + * + * @param text - The text/HTML content to parse + * @returns Array of unique user IDs that were mentioned + * + * @example + * // TipTap format + * extractMentions('@John'); + * // Returns: ['user123'] + * + * // Plain text format (fallback) + * extractMentions('Hello @john.doe, please review'); + * // Returns: ['john.doe'] + */ +export function extractMentions(text: string): string[] { + const mentions: string[] = []; + + // Match TipTap mention format: + // or any element with data-id attribute in a mention context + const tiptapRegex = /data-id="([^"]+)"/g; + let match; + while ((match = tiptapRegex.exec(text)) !== null) { + mentions.push(match[1]); + } + + // Match plain text @Name[userId] format used by MentionInput + // This format embeds the userId in brackets for parsing + const bracketMentionRegex = /@[^\[\]]+\[([^\[\]]+)\]/g; + while ((match = bracketMentionRegex.exec(text)) !== null) { + if (!mentions.includes(match[1])) { + mentions.push(match[1]); + } + } + + // Return unique mentions + return Array.from(new Set(mentions)); +} + +/** + * Extract a text snippet from content for use in notifications + * Strips HTML tags and truncates to specified length + * + * @param content - The content to extract snippet from + * @param maxLength - Maximum length of snippet (default: 120) + * @returns Clean text snippet + */ +export function extractSnippet(content: string, maxLength: number = 120): string { + // Strip HTML tags + const text = content.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); + + if (text.length <= maxLength) { + return text; + } + + return text.slice(0, maxLength - 3) + '...'; +} diff --git a/src/lib/notifications.ts b/src/lib/notifications.ts index 145b001c..9f9ea335 100644 --- a/src/lib/notifications.ts +++ b/src/lib/notifications.ts @@ -46,6 +46,7 @@ export { createPriorityChangedEvent, createDueDateChangedEvent, createCommentAddedEvent, + createMentionEvent, createAttachmentAddedEvent, createAttachmentDeletedEvent, getNotificationTitle, diff --git a/src/lib/notifications/dispatcher.ts b/src/lib/notifications/dispatcher.ts index cfee9ec9..384c273b 100644 --- a/src/lib/notifications/dispatcher.ts +++ b/src/lib/notifications/dispatcher.ts @@ -189,6 +189,11 @@ class NotificationDispatcher { (event.metadata.mentionedUserIds as string[]).forEach((id) => recipients.add(id)); } + // Add mentioned user for direct mention events + if (event.type === WorkitemEventType.WORKITEM_MENTION && event.metadata?.mentionedUserId) { + recipients.add(event.metadata.mentionedUserId as string); + } + return Array.from(recipients); } @@ -319,6 +324,7 @@ class NotificationDispatcher { case WorkitemEventType.WORKITEM_DELETED: return "task_deleted"; case WorkitemEventType.WORKITEM_COMMENT_ADDED: + case WorkitemEventType.WORKITEM_MENTION: return "task_comment"; default: return "task_updated"; diff --git a/src/lib/notifications/events.ts b/src/lib/notifications/events.ts index 4fd134a4..7a423217 100644 --- a/src/lib/notifications/events.ts +++ b/src/lib/notifications/events.ts @@ -137,6 +137,26 @@ export function createCommentAddedEvent( }); } +export function createMentionEvent( + workitem: Task, + triggeredBy: string, + triggeredByName: string, + mentionedUserId: string, + snippet: string +): WorkitemEvent { + return createWorkitemEvent({ + type: WorkitemEventType.WORKITEM_MENTION, + workitem, + triggeredBy, + triggeredByName, + metadata: { + mentionedBy: triggeredBy, + mentionedUserId, + snippet: snippet.slice(0, 120), + }, + }); +} + export function createAttachmentAddedEvent( workitem: Task, triggeredBy: string, @@ -197,6 +217,8 @@ export function getNotificationTitle(event: WorkitemEvent): string { return `⚠️ Overdue: ${taskName}`; case WorkitemEventType.WORKITEM_COMMENT_ADDED: return "New Comment"; + case WorkitemEventType.WORKITEM_MENTION: + return "You were mentioned"; case WorkitemEventType.WORKITEM_ATTACHMENT_ADDED: return "Attachment Added"; case WorkitemEventType.WORKITEM_ATTACHMENT_DELETED: @@ -231,6 +253,8 @@ export function getNotificationSummary(event: WorkitemEvent): string { return `"${taskName}" is overdue`; case WorkitemEventType.WORKITEM_COMMENT_ADDED: return `${byName} commented on "${taskName}"`; + case WorkitemEventType.WORKITEM_MENTION: + return `${byName} mentioned you in "${taskName}"`; case WorkitemEventType.WORKITEM_ATTACHMENT_ADDED: return `${byName} added an attachment to "${taskName}"`; case WorkitemEventType.WORKITEM_ATTACHMENT_DELETED: @@ -263,6 +287,7 @@ export function getDefaultChannelsForEvent(event: WorkitemEvent): ("socket" | "e case WorkitemEventType.WORKITEM_DUE_DATE_CHANGED: case WorkitemEventType.WORKITEM_UPDATED: case WorkitemEventType.WORKITEM_COMMENT_ADDED: + case WorkitemEventType.WORKITEM_MENTION: channels.push("email"); break; diff --git a/src/lib/notifications/index.ts b/src/lib/notifications/index.ts index a6574aeb..c7d49b81 100644 --- a/src/lib/notifications/index.ts +++ b/src/lib/notifications/index.ts @@ -42,6 +42,7 @@ export { createPriorityChangedEvent, createDueDateChangedEvent, createCommentAddedEvent, + createMentionEvent, createAttachmentAddedEvent, createAttachmentDeletedEvent, getNotificationTitle, diff --git a/src/lib/notifications/types.ts b/src/lib/notifications/types.ts index a68d69d4..3b90bb4d 100644 --- a/src/lib/notifications/types.ts +++ b/src/lib/notifications/types.ts @@ -30,6 +30,7 @@ export enum WorkitemEventType { // Related entity events WORKITEM_COMMENT_ADDED = "WORKITEM_COMMENT_ADDED", + WORKITEM_MENTION = "WORKITEM_MENTION", WORKITEM_ATTACHMENT_ADDED = "WORKITEM_ATTACHMENT_ADDED", WORKITEM_ATTACHMENT_DELETED = "WORKITEM_ATTACHMENT_DELETED", @@ -89,6 +90,11 @@ export interface WorkitemEventMetadata { mentionedUserIds?: string[]; isMentioned?: boolean; + // Mention + mentionedBy?: string; + mentionedUserId?: string; + snippet?: string; + // Attachment attachmentId?: string; attachmentName?: string;