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:
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user