feat: enterprise notification & task management system
Phase N.1 — Data Model: - Extend Notification model with category, priority, task fields (status, action, assignee, dueDate, completedAt/By), reminder fields (remindAt, recurrence, nextRemindAt), and targeting metadata (sourceId, senderId, channel) - Add NotificationCategory, NotificationPriority, TaskStatus enums - Add NotificationBroadcast model for group notifications - Shared types with parseTaskAction()/buildTaskAction() helpers Phase N.2 — API: - Extend notification router: listTasks, taskCounts, updateTaskStatus, createReminder/update/delete/list, createBroadcast/listBroadcasts, createTask, assignTask, delete - Broadcast targeting: resolve recipients by user/role/project/orgUnit/all - Task-action registry: approve_vacation, reject_vacation, confirm_assignment - Reminder scheduler: 60s poll interval, recurring support, catch-up on start - SSE events: TASK_ASSIGNED, TASK_COMPLETED, TASK_STATUS_CHANGED, REMINDER_DUE, BROADCAST_SENT Phase N.3 — AI Assistant: - 7 new tools: list_tasks, get_task_detail, update_task_status, execute_task_action, create_reminder, create_task_for_user, send_broadcast - execute_task_action dispatches to task-action registry with per-action permission checks, marks tasks as completed by AI Phase N.4 — Frontend: - Enhanced NotificationBell with task badge, tabs (All/Tasks/Reminders) - TaskCard component with priority badges, due dates, action buttons - ReminderModal for creating/editing personal reminders - BroadcastModal for targeted group notifications (manager+) - NotificationCenter full-page with 5 tabs and bulk actions - TaskWidget dashboard widget showing open tasks - Admin broadcast management page - AppShell nav links for Notifications and Broadcasts - SSE hook handlers for task/reminder events Phase N.5 — Auto-Tasks: - Vacation create → APPROVAL tasks for all managers - Vacation approve/reject → mark approval tasks as DONE - Demand create → TASK for managers to fill staffing needs Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -6,12 +6,23 @@
|
||||
import { prisma } from "@planarchy/db";
|
||||
import { computeBudgetStatus } from "@planarchy/engine";
|
||||
import type { PermissionKey } from "@planarchy/shared";
|
||||
import { parseTaskAction } from "@planarchy/shared";
|
||||
import { createDalleClient, isDalleConfigured, parseAiError } from "../ai-client.js";
|
||||
import { getTaskAction } from "../lib/task-actions.js";
|
||||
import { resolveRecipients } from "../lib/notification-targeting.js";
|
||||
import {
|
||||
emitNotificationCreated,
|
||||
emitTaskAssigned,
|
||||
emitTaskCompleted,
|
||||
emitTaskStatusChanged,
|
||||
emitBroadcastSent,
|
||||
} from "../sse/event-bus.js";
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ToolContext = {
|
||||
db: typeof prisma;
|
||||
userId: string;
|
||||
userRole: string;
|
||||
permissions: Set<PermissionKey>;
|
||||
};
|
||||
@@ -1036,6 +1047,125 @@ export const TOOL_DEFINITIONS: ToolDef[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ── TASK MANAGEMENT ──
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "list_tasks",
|
||||
description: "List open/pending tasks and approvals for the current user. Returns actionable items that need attention.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { type: "string", enum: ["OPEN", "IN_PROGRESS", "DONE", "DISMISSED"], description: "Filter by status. Default: OPEN" },
|
||||
limit: { type: "integer", description: "Max results (default 10)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "get_task_detail",
|
||||
description: "Get details of a specific task/notification including linked entity information.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
taskId: { type: "string", description: "Notification/task ID" },
|
||||
},
|
||||
required: ["taskId"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "update_task_status",
|
||||
description: "Update the status of a task. Mark as IN_PROGRESS, DONE, or DISMISSED.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
taskId: { type: "string", description: "Task/notification ID" },
|
||||
status: { type: "string", enum: ["OPEN", "IN_PROGRESS", "DONE", "DISMISSED"], description: "New status" },
|
||||
},
|
||||
required: ["taskId", "status"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "execute_task_action",
|
||||
description: "Execute the machine-readable action associated with a task. For example: approve a vacation, confirm an assignment, etc. The action is encoded in the task's taskAction field.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
taskId: { type: "string", description: "Task/notification ID containing the action to execute" },
|
||||
},
|
||||
required: ["taskId"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "create_reminder",
|
||||
description: "Create a personal reminder for the current user. Can be one-shot or recurring.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string", description: "Reminder title" },
|
||||
body: { type: "string", description: "Optional details" },
|
||||
remindAt: { type: "string", format: "date-time", description: "When to remind (ISO 8601 datetime)" },
|
||||
recurrence: { type: "string", enum: ["daily", "weekly", "monthly"], description: "Optional recurrence pattern" },
|
||||
entityId: { type: "string", description: "Optional: linked entity ID (project, resource, etc.)" },
|
||||
entityType: { type: "string", description: "Optional: entity type (project, resource, vacation, etc.)" },
|
||||
},
|
||||
required: ["title", "remindAt"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "create_task_for_user",
|
||||
description: "Create a task for a specific user. Requires manageProjects or manageResources permission. The task appears in their task list.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
userId: { type: "string", description: "Target user ID" },
|
||||
title: { type: "string", description: "Task title" },
|
||||
body: { type: "string", description: "Task description" },
|
||||
priority: { type: "string", enum: ["LOW", "NORMAL", "HIGH", "URGENT"], description: "Priority (default NORMAL)" },
|
||||
dueDate: { type: "string", format: "date-time", description: "Optional due date (ISO 8601)" },
|
||||
taskAction: { type: "string", description: "Optional machine-readable action (format: action_name:entity_id)" },
|
||||
entityId: { type: "string", description: "Optional linked entity ID" },
|
||||
entityType: { type: "string", description: "Optional entity type" },
|
||||
},
|
||||
required: ["userId", "title"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "send_broadcast",
|
||||
description: "Send a notification to a group of users (by role, project members, org unit, or all). Requires manager permission.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string", description: "Notification title" },
|
||||
body: { type: "string", description: "Notification body" },
|
||||
targetType: { type: "string", enum: ["user", "role", "project", "orgUnit", "all"], description: "Target audience type" },
|
||||
targetValue: { type: "string", description: "Target value: user ID, role name (ADMIN/MANAGER/CONTROLLER/USER/VIEWER), project ID, or org unit ID" },
|
||||
priority: { type: "string", enum: ["LOW", "NORMAL", "HIGH", "URGENT"], description: "Priority (default NORMAL)" },
|
||||
channel: { type: "string", enum: ["in_app", "email", "both"], description: "Delivery channel (default in_app)" },
|
||||
link: { type: "string", description: "Optional deep-link URL" },
|
||||
},
|
||||
required: ["title", "targetType"],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
@@ -3496,6 +3626,392 @@ const executors = {
|
||||
|
||||
return { __action: "invalidate", scope: ["project"], success: true, message: `Removed cover art from project "${project.name}"` };
|
||||
},
|
||||
|
||||
// ── TASK MANAGEMENT ──
|
||||
|
||||
async list_tasks(params: { status?: string; limit?: number }, ctx: ToolContext) {
|
||||
const limit = Math.min(params.limit ?? 10, 50);
|
||||
const status = params.status ?? "OPEN";
|
||||
const tasks = await ctx.db.notification.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ userId: ctx.userId },
|
||||
{ assigneeId: ctx.userId },
|
||||
],
|
||||
category: { in: ["TASK", "APPROVAL"] },
|
||||
taskStatus: status as "OPEN" | "IN_PROGRESS" | "DONE" | "DISMISSED",
|
||||
},
|
||||
select: {
|
||||
id: true, title: true, body: true, priority: true,
|
||||
taskStatus: true, taskAction: true, dueDate: true,
|
||||
entityId: true, entityType: true, createdAt: true,
|
||||
},
|
||||
take: limit,
|
||||
orderBy: [{ priority: "desc" }, { createdAt: "desc" }],
|
||||
});
|
||||
return tasks.map((t) => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
body: t.body,
|
||||
priority: t.priority,
|
||||
taskStatus: t.taskStatus,
|
||||
taskAction: t.taskAction,
|
||||
dueDate: fmtDate(t.dueDate),
|
||||
entityId: t.entityId,
|
||||
entityType: t.entityType,
|
||||
createdAt: fmtDate(t.createdAt),
|
||||
}));
|
||||
},
|
||||
|
||||
async get_task_detail(params: { taskId: string }, ctx: ToolContext) {
|
||||
const task = await ctx.db.notification.findUnique({
|
||||
where: { id: params.taskId },
|
||||
select: {
|
||||
id: true, title: true, body: true, type: true, priority: true,
|
||||
category: true, taskStatus: true, taskAction: true,
|
||||
dueDate: true, entityId: true, entityType: true,
|
||||
completedAt: true, completedBy: true,
|
||||
createdAt: true, userId: true, assigneeId: true,
|
||||
sender: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
if (!task) return { error: `Task not found: ${params.taskId}` };
|
||||
|
||||
// Verify the user has access to this task
|
||||
if (task.userId !== ctx.userId && task.assigneeId !== ctx.userId) {
|
||||
return { error: "Access denied: this task does not belong to you" };
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result: Record<string, any> = {
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
body: task.body,
|
||||
type: task.type,
|
||||
priority: task.priority,
|
||||
category: task.category,
|
||||
taskStatus: task.taskStatus,
|
||||
taskAction: task.taskAction,
|
||||
dueDate: fmtDate(task.dueDate),
|
||||
entityId: task.entityId,
|
||||
entityType: task.entityType,
|
||||
completedAt: fmtDate(task.completedAt),
|
||||
completedBy: task.completedBy,
|
||||
createdAt: fmtDate(task.createdAt),
|
||||
senderName: task.sender?.name ?? null,
|
||||
};
|
||||
|
||||
// Enrich with linked entity details
|
||||
if (task.entityId && task.entityType) {
|
||||
try {
|
||||
if (task.entityType === "project") {
|
||||
const project = await ctx.db.project.findUnique({
|
||||
where: { id: task.entityId },
|
||||
select: { id: true, name: true, shortCode: true, status: true },
|
||||
});
|
||||
if (project) result.linkedEntity = project;
|
||||
} else if (task.entityType === "vacation") {
|
||||
const vacation = await ctx.db.vacation.findUnique({
|
||||
where: { id: task.entityId },
|
||||
select: {
|
||||
id: true, type: true, status: true, startDate: true, endDate: true,
|
||||
resource: { select: { displayName: true } },
|
||||
},
|
||||
});
|
||||
if (vacation) {
|
||||
result.linkedEntity = {
|
||||
id: vacation.id,
|
||||
type: vacation.type,
|
||||
status: vacation.status,
|
||||
startDate: fmtDate(vacation.startDate),
|
||||
endDate: fmtDate(vacation.endDate),
|
||||
resourceName: vacation.resource.displayName,
|
||||
};
|
||||
}
|
||||
} else if (task.entityType === "assignment" || task.entityType === "allocation") {
|
||||
const assignment = await ctx.db.assignment.findUnique({
|
||||
where: { id: task.entityId },
|
||||
select: {
|
||||
id: true, status: true, startDate: true, endDate: true,
|
||||
resource: { select: { displayName: true } },
|
||||
project: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
if (assignment) {
|
||||
result.linkedEntity = {
|
||||
id: assignment.id,
|
||||
status: assignment.status,
|
||||
startDate: fmtDate(assignment.startDate),
|
||||
endDate: fmtDate(assignment.endDate),
|
||||
resourceName: assignment.resource.displayName,
|
||||
projectName: assignment.project.name,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Entity may have been deleted — ignore
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
async update_task_status(params: { taskId: string; status: string }, ctx: ToolContext) {
|
||||
const task = await ctx.db.notification.findUnique({
|
||||
where: { id: params.taskId },
|
||||
select: { id: true, userId: true, assigneeId: true, taskStatus: true },
|
||||
});
|
||||
if (!task) return { error: `Task not found: ${params.taskId}` };
|
||||
if (task.userId !== ctx.userId && task.assigneeId !== ctx.userId) {
|
||||
return { error: "Access denied: this task does not belong to you" };
|
||||
}
|
||||
|
||||
const newStatus = params.status as "OPEN" | "IN_PROGRESS" | "DONE" | "DISMISSED";
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data: Record<string, any> = { taskStatus: newStatus };
|
||||
if (newStatus === "DONE") {
|
||||
data.completedAt = new Date();
|
||||
data.completedBy = "ai-assistant";
|
||||
}
|
||||
|
||||
await ctx.db.notification.update({
|
||||
where: { id: params.taskId },
|
||||
data,
|
||||
});
|
||||
|
||||
emitTaskStatusChanged(task.userId, task.id);
|
||||
if (newStatus === "DONE") {
|
||||
emitTaskCompleted(task.userId, task.id);
|
||||
}
|
||||
|
||||
return { __action: "invalidate", scope: ["notification"], success: true, message: `Task status updated to ${newStatus}` };
|
||||
},
|
||||
|
||||
async execute_task_action(params: { taskId: string }, ctx: ToolContext) {
|
||||
// 1. Fetch the notification
|
||||
const task = await ctx.db.notification.findUnique({
|
||||
where: { id: params.taskId },
|
||||
select: {
|
||||
id: true, userId: true, assigneeId: true,
|
||||
taskAction: true, taskStatus: true,
|
||||
},
|
||||
});
|
||||
if (!task) return { error: `Task not found: ${params.taskId}` };
|
||||
if (task.userId !== ctx.userId && task.assigneeId !== ctx.userId) {
|
||||
return { error: "Access denied: this task does not belong to you" };
|
||||
}
|
||||
if (!task.taskAction) {
|
||||
return { error: "This task has no executable action" };
|
||||
}
|
||||
if (task.taskStatus === "DONE") {
|
||||
return { error: "This task is already completed" };
|
||||
}
|
||||
|
||||
// 2. Parse taskAction
|
||||
const parsed = parseTaskAction(task.taskAction);
|
||||
if (!parsed) {
|
||||
return { error: `Invalid taskAction format: ${task.taskAction}` };
|
||||
}
|
||||
|
||||
// 3. Look up handler in TASK_ACTION_REGISTRY
|
||||
const handler = getTaskAction(parsed.action);
|
||||
if (!handler) {
|
||||
return { error: `Unknown action: ${parsed.action}` };
|
||||
}
|
||||
|
||||
// 4. Check permission
|
||||
if (handler.permission && !ctx.permissions.has(handler.permission as PermissionKey)) {
|
||||
return { error: `Permission denied: you need "${handler.permission}" to perform this action` };
|
||||
}
|
||||
|
||||
// 5. Execute the action
|
||||
const actionResult = await handler.execute(parsed.entityId, ctx.db, ctx.userId);
|
||||
if (!actionResult.success) {
|
||||
return { error: actionResult.message };
|
||||
}
|
||||
|
||||
// 6. Mark the task as DONE
|
||||
await ctx.db.notification.update({
|
||||
where: { id: params.taskId },
|
||||
data: {
|
||||
taskStatus: "DONE",
|
||||
completedAt: new Date(),
|
||||
completedBy: "ai-assistant",
|
||||
},
|
||||
});
|
||||
|
||||
emitTaskCompleted(task.userId, task.id);
|
||||
|
||||
return {
|
||||
__action: "invalidate",
|
||||
scope: ["notification"],
|
||||
success: true,
|
||||
message: actionResult.message,
|
||||
action: parsed.action,
|
||||
entityId: parsed.entityId,
|
||||
};
|
||||
},
|
||||
|
||||
async create_reminder(params: {
|
||||
title: string;
|
||||
body?: string;
|
||||
remindAt: string;
|
||||
recurrence?: string;
|
||||
entityId?: string;
|
||||
entityType?: string;
|
||||
}, ctx: ToolContext) {
|
||||
const remindAt = new Date(params.remindAt);
|
||||
if (isNaN(remindAt.getTime())) {
|
||||
return { error: "Invalid remindAt date format. Use ISO 8601 (e.g. 2026-03-20T09:00:00Z)" };
|
||||
}
|
||||
|
||||
const notification = await ctx.db.notification.create({
|
||||
data: {
|
||||
userId: ctx.userId,
|
||||
type: "REMINDER",
|
||||
title: params.title,
|
||||
category: "REMINDER",
|
||||
remindAt,
|
||||
nextRemindAt: remindAt,
|
||||
...(params.body !== undefined ? { body: params.body } : {}),
|
||||
...(params.recurrence !== undefined ? { recurrence: params.recurrence } : {}),
|
||||
...(params.entityId !== undefined ? { entityId: params.entityId } : {}),
|
||||
...(params.entityType !== undefined ? { entityType: params.entityType } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
emitNotificationCreated(ctx.userId, notification.id);
|
||||
|
||||
return {
|
||||
__action: "invalidate",
|
||||
scope: ["notification"],
|
||||
success: true,
|
||||
message: `Reminder "${params.title}" created for ${fmtDate(remindAt)}`,
|
||||
reminderId: notification.id,
|
||||
...(params.recurrence !== undefined ? { recurrence: params.recurrence } : {}),
|
||||
};
|
||||
},
|
||||
|
||||
async create_task_for_user(params: {
|
||||
userId: string;
|
||||
title: string;
|
||||
body?: string;
|
||||
priority?: string;
|
||||
dueDate?: string;
|
||||
taskAction?: string;
|
||||
entityId?: string;
|
||||
entityType?: string;
|
||||
}, ctx: ToolContext) {
|
||||
assertPermission(ctx, "manageProjects" as PermissionKey);
|
||||
|
||||
// Verify target user exists
|
||||
const targetUser = await ctx.db.user.findUnique({
|
||||
where: { id: params.userId },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
if (!targetUser) return { error: `User not found: ${params.userId}` };
|
||||
|
||||
const notification = await ctx.db.notification.create({
|
||||
data: {
|
||||
userId: params.userId,
|
||||
type: "TASK_ASSIGNED",
|
||||
title: params.title,
|
||||
category: "TASK",
|
||||
taskStatus: "OPEN",
|
||||
senderId: ctx.userId,
|
||||
priority: (params.priority ?? "NORMAL") as "LOW" | "NORMAL" | "HIGH" | "URGENT",
|
||||
...(params.body !== undefined ? { body: params.body } : {}),
|
||||
...(params.dueDate !== undefined ? { dueDate: new Date(params.dueDate) } : {}),
|
||||
...(params.taskAction !== undefined ? { taskAction: params.taskAction } : {}),
|
||||
...(params.entityId !== undefined ? { entityId: params.entityId } : {}),
|
||||
...(params.entityType !== undefined ? { entityType: params.entityType } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
emitTaskAssigned(params.userId, notification.id);
|
||||
|
||||
return {
|
||||
__action: "invalidate",
|
||||
scope: ["notification"],
|
||||
success: true,
|
||||
message: `Task "${params.title}" created for ${targetUser.name ?? params.userId}`,
|
||||
taskId: notification.id,
|
||||
};
|
||||
},
|
||||
|
||||
async send_broadcast(params: {
|
||||
title: string;
|
||||
body?: string;
|
||||
targetType: string;
|
||||
targetValue?: string;
|
||||
priority?: string;
|
||||
channel?: string;
|
||||
link?: string;
|
||||
}, ctx: ToolContext) {
|
||||
assertPermission(ctx, "manageProjects" as PermissionKey);
|
||||
|
||||
// Resolve recipients
|
||||
const recipientIds = await resolveRecipients(
|
||||
params.targetType,
|
||||
params.targetValue,
|
||||
ctx.db,
|
||||
ctx.userId, // exclude sender
|
||||
);
|
||||
|
||||
if (recipientIds.length === 0) {
|
||||
return { error: "No recipients found for the given target" };
|
||||
}
|
||||
|
||||
const priority = (params.priority ?? "NORMAL") as "LOW" | "NORMAL" | "HIGH" | "URGENT";
|
||||
const channel = params.channel ?? "in_app";
|
||||
|
||||
// Create broadcast record
|
||||
const broadcast = await ctx.db.notificationBroadcast.create({
|
||||
data: {
|
||||
senderId: ctx.userId,
|
||||
title: params.title,
|
||||
targetType: params.targetType,
|
||||
priority,
|
||||
channel,
|
||||
recipientCount: recipientIds.length,
|
||||
sentAt: new Date(),
|
||||
...(params.body !== undefined ? { body: params.body } : {}),
|
||||
...(params.targetValue !== undefined ? { targetValue: params.targetValue } : {}),
|
||||
...(params.link !== undefined ? { link: params.link } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
// Create individual notifications for each recipient
|
||||
await ctx.db.notification.createMany({
|
||||
data: recipientIds.map((userId) => ({
|
||||
userId,
|
||||
type: "BROADCAST",
|
||||
title: params.title,
|
||||
category: "NOTIFICATION" as const,
|
||||
priority,
|
||||
channel,
|
||||
senderId: ctx.userId,
|
||||
sourceId: broadcast.id,
|
||||
...(params.body !== undefined ? { body: params.body } : {}),
|
||||
...(params.link !== undefined ? { link: params.link } : {}),
|
||||
})),
|
||||
});
|
||||
|
||||
// Emit SSE events for each recipient
|
||||
for (const userId of recipientIds) {
|
||||
emitNotificationCreated(userId, broadcast.id);
|
||||
}
|
||||
emitBroadcastSent(broadcast.id, recipientIds.length);
|
||||
|
||||
return {
|
||||
__action: "invalidate",
|
||||
scope: ["notification"],
|
||||
success: true,
|
||||
message: `Broadcast "${params.title}" sent to ${recipientIds.length} recipients`,
|
||||
broadcastId: broadcast.id,
|
||||
recipientCount: recipientIds.length,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Executor ───────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user