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;