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:
2026-03-18 11:51:49 +01:00
parent 093e13b88f
commit d0f04f13f8
26 changed files with 3404 additions and 54 deletions
+516
View File
@@ -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 ───────────────────────────────────────────────────────────────