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
+103 -2
View File
@@ -1,9 +1,9 @@
import { UpdateVacationStatusSchema, getPublicHolidays } from "@planarchy/shared";
import { UpdateVacationStatusSchema, getPublicHolidays, buildTaskAction } from "@planarchy/shared";
import { VacationStatus, VacationType } from "@planarchy/db";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { findUniqueOrThrow } from "../db/helpers.js";
import { emitVacationCreated, emitVacationUpdated, emitNotificationCreated } from "../sse/event-bus.js";
import { emitVacationCreated, emitVacationUpdated, emitNotificationCreated, emitTaskAssigned } from "../sse/event-bus.js";
import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js";
import { sendEmail } from "../lib/email.js";
import { anonymizeResource, anonymizeUser, getAnonymizationDirectory } from "../lib/anonymization.js";
@@ -217,6 +217,41 @@ export const vacationRouter = createTRPCRouter({
emitVacationCreated({ id: vacation.id, resourceId: vacation.resourceId, status: vacation.status });
// Create approval tasks for managers when a non-manager submits a vacation request
if (status === VacationStatus.PENDING) {
const resourceName = vacation.resource?.displayName ?? "Unknown";
const startStr = input.startDate.toISOString().slice(0, 10);
const endStr = input.endDate.toISOString().slice(0, 10);
const managers = await ctx.db.user.findMany({
where: { systemRole: { in: ["ADMIN", "MANAGER"] } },
select: { id: true },
});
for (const manager of managers) {
if (manager.id === userRecord.id) continue;
const task = await ctx.db.notification.create({
data: {
userId: manager.id,
category: "APPROVAL",
type: "VACATION_APPROVAL",
priority: "NORMAL",
title: `Vacation approval: ${resourceName}`,
body: `${resourceName} requests ${input.type} from ${startStr} to ${endStr}`,
taskStatus: "OPEN",
taskAction: buildTaskAction("approve_vacation", vacation.id),
entityId: vacation.id,
entityType: "vacation",
link: "/vacations",
senderId: userRecord.id,
channel: "in_app",
},
});
emitNotificationCreated(manager.id, task.id);
emitTaskAssigned(manager.id, task.id);
}
}
const directory = await getAnonymizationDirectory(ctx.db);
return anonymizeVacationRecord(vacation, directory);
}),
@@ -253,6 +288,20 @@ export const vacationRouter = createTRPCRouter({
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
// Mark approval tasks as DONE
await ctx.db.notification.updateMany({
where: {
taskAction: buildTaskAction("approve_vacation", input.id),
category: "APPROVAL",
taskStatus: "OPEN",
},
data: {
taskStatus: "DONE",
completedAt: new Date(),
...(userRecord?.id ? { completedBy: userRecord.id } : {}),
},
});
if (existing.status === VacationStatus.PENDING) {
void notifyVacationStatus(ctx.db, updated.id, updated.resourceId, VacationStatus.APPROVED);
}
@@ -283,6 +332,25 @@ export const vacationRouter = createTRPCRouter({
});
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
// Mark approval tasks as DONE
const userRecord = await ctx.db.user.findUnique({
where: { email: ctx.session.user?.email ?? "" },
select: { id: true },
});
await ctx.db.notification.updateMany({
where: {
taskAction: buildTaskAction("approve_vacation", input.id),
category: "APPROVAL",
taskStatus: "OPEN",
},
data: {
taskStatus: "DONE",
completedAt: new Date(),
...(userRecord?.id ? { completedBy: userRecord.id } : {}),
},
});
void notifyVacationStatus(ctx.db, updated.id, updated.resourceId, VacationStatus.REJECTED, input.rejectionReason);
return updated;
@@ -317,6 +385,20 @@ export const vacationRouter = createTRPCRouter({
for (const v of vacations) {
emitVacationUpdated({ id: v.id, resourceId: v.resourceId, status: VacationStatus.APPROVED });
void notifyVacationStatus(ctx.db, v.id, v.resourceId, VacationStatus.APPROVED);
// Mark approval tasks as DONE
await ctx.db.notification.updateMany({
where: {
taskAction: buildTaskAction("approve_vacation", v.id),
category: "APPROVAL",
taskStatus: "OPEN",
},
data: {
taskStatus: "DONE",
completedAt: new Date(),
...(userRecord?.id ? { completedBy: userRecord.id } : {}),
},
});
}
return { approved: vacations.length };
@@ -333,6 +415,11 @@ export const vacationRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
const userRecord = await ctx.db.user.findUnique({
where: { email: ctx.session.user?.email ?? "" },
select: { id: true },
});
const vacations = await ctx.db.vacation.findMany({
where: { id: { in: input.ids }, status: VacationStatus.PENDING },
select: { id: true, resourceId: true },
@@ -349,6 +436,20 @@ export const vacationRouter = createTRPCRouter({
for (const v of vacations) {
emitVacationUpdated({ id: v.id, resourceId: v.resourceId, status: VacationStatus.REJECTED });
void notifyVacationStatus(ctx.db, v.id, v.resourceId, VacationStatus.REJECTED, input.rejectionReason);
// Mark approval tasks as DONE
await ctx.db.notification.updateMany({
where: {
taskAction: buildTaskAction("approve_vacation", v.id),
category: "APPROVAL",
taskStatus: "OPEN",
},
data: {
taskStatus: "DONE",
completedAt: new Date(),
...(userRecord?.id ? { completedBy: userRecord.id } : {}),
},
});
}
return { rejected: vacations.length };