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:
@@ -0,0 +1,71 @@
|
||||
import { prisma } from "@planarchy/db";
|
||||
|
||||
type PrismaClient = typeof prisma;
|
||||
|
||||
/**
|
||||
* Resolve recipient user IDs for a broadcast target.
|
||||
* Deduplicates results and optionally excludes the sender.
|
||||
*/
|
||||
export async function resolveRecipients(
|
||||
targetType: string,
|
||||
targetValue: string | null | undefined,
|
||||
db: PrismaClient,
|
||||
excludeUserId?: string,
|
||||
): Promise<string[]> {
|
||||
let userIds: string[] = [];
|
||||
|
||||
switch (targetType) {
|
||||
case "user":
|
||||
if (targetValue) userIds = [targetValue];
|
||||
break;
|
||||
|
||||
case "role": {
|
||||
// Find all users with the given systemRole
|
||||
const roleUsers = await db.user.findMany({
|
||||
where: { systemRole: targetValue as "ADMIN" | "MANAGER" | "CONTROLLER" | "USER" | "VIEWER" },
|
||||
select: { id: true },
|
||||
});
|
||||
userIds = roleUsers.map((u) => u.id);
|
||||
break;
|
||||
}
|
||||
|
||||
case "project": {
|
||||
// Find all resources with assignments on this project, then their linked users
|
||||
if (!targetValue) break;
|
||||
const assignments = await db.assignment.findMany({
|
||||
where: { projectId: targetValue, status: { not: "CANCELLED" } },
|
||||
select: { resource: { select: { userId: true } } },
|
||||
});
|
||||
userIds = assignments
|
||||
.map((a) => a.resource.userId)
|
||||
.filter((id): id is string => !!id);
|
||||
break;
|
||||
}
|
||||
|
||||
case "orgUnit": {
|
||||
// Find all resources in this orgUnit, then their linked users
|
||||
if (!targetValue) break;
|
||||
const resources = await db.resource.findMany({
|
||||
where: { orgUnitId: targetValue, isActive: true },
|
||||
select: { userId: true },
|
||||
});
|
||||
userIds = resources
|
||||
.map((r) => r.userId)
|
||||
.filter((id): id is string => !!id);
|
||||
break;
|
||||
}
|
||||
|
||||
case "all": {
|
||||
// User model has no isActive — get all users
|
||||
const allUsers = await db.user.findMany({
|
||||
select: { id: true },
|
||||
});
|
||||
userIds = allUsers.map((u) => u.id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate and exclude sender
|
||||
const unique = [...new Set(userIds)];
|
||||
return excludeUserId ? unique.filter((id) => id !== excludeUserId) : unique;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { prisma } from "@planarchy/db";
|
||||
import { emitReminderDue, emitNotificationCreated } from "../sse/event-bus.js";
|
||||
|
||||
const POLL_INTERVAL_MS = 60_000; // 60 seconds
|
||||
|
||||
function computeNextRemindAt(current: Date, recurrence: string): Date {
|
||||
const next = new Date(current);
|
||||
switch (recurrence) {
|
||||
case "daily":
|
||||
next.setDate(next.getDate() + 1);
|
||||
break;
|
||||
case "weekly":
|
||||
next.setDate(next.getDate() + 7);
|
||||
break;
|
||||
case "monthly":
|
||||
next.setMonth(next.getMonth() + 1);
|
||||
break;
|
||||
default:
|
||||
return current;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
async function processReminders() {
|
||||
const now = new Date();
|
||||
|
||||
// Find all due reminders
|
||||
const dueReminders = await prisma.notification.findMany({
|
||||
where: {
|
||||
category: "REMINDER",
|
||||
nextRemindAt: { lte: now },
|
||||
},
|
||||
take: 100, // process in batches
|
||||
});
|
||||
|
||||
for (const reminder of dueReminders) {
|
||||
try {
|
||||
if (reminder.recurrence) {
|
||||
// Recurring: create a new notification for this occurrence, advance nextRemindAt
|
||||
const notification = await prisma.notification.create({
|
||||
data: {
|
||||
userId: reminder.userId,
|
||||
category: "NOTIFICATION",
|
||||
type: "REMINDER_DUE",
|
||||
priority: reminder.priority,
|
||||
title: reminder.title,
|
||||
body: reminder.body,
|
||||
entityId: reminder.entityId,
|
||||
entityType: reminder.entityType,
|
||||
link: reminder.link,
|
||||
sourceId: reminder.id,
|
||||
channel: "in_app",
|
||||
},
|
||||
});
|
||||
|
||||
// Advance to next occurrence
|
||||
await prisma.notification.update({
|
||||
where: { id: reminder.id },
|
||||
data: {
|
||||
nextRemindAt: computeNextRemindAt(reminder.nextRemindAt!, reminder.recurrence),
|
||||
},
|
||||
});
|
||||
|
||||
emitNotificationCreated(reminder.userId, notification.id);
|
||||
emitReminderDue(reminder.userId, notification.id);
|
||||
} else {
|
||||
// One-shot: mark the reminder as "fired" by clearing nextRemindAt
|
||||
await prisma.notification.update({
|
||||
where: { id: reminder.id },
|
||||
data: { nextRemindAt: null },
|
||||
});
|
||||
|
||||
emitReminderDue(reminder.userId, reminder.id);
|
||||
emitNotificationCreated(reminder.userId, reminder.id);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[ReminderScheduler] Error processing reminder ${reminder.id}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
export function startReminderScheduler(): void {
|
||||
if (intervalId) return; // already running
|
||||
console.log("[ReminderScheduler] Starting (poll every 60s)");
|
||||
// Run immediately to catch up on overdue reminders
|
||||
void processReminders();
|
||||
intervalId = setInterval(() => void processReminders(), POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
export function stopReminderScheduler(): void {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
console.log("[ReminderScheduler] Stopped");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { prisma } from "@planarchy/db";
|
||||
|
||||
type PrismaClient = typeof prisma;
|
||||
|
||||
export interface TaskActionResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface TaskActionHandler {
|
||||
/** PermissionKey string value required to execute this action */
|
||||
permission: string;
|
||||
execute: (entityId: string, db: PrismaClient, executorId: string) => Promise<TaskActionResult>;
|
||||
}
|
||||
|
||||
export const TASK_ACTION_REGISTRY: Record<string, TaskActionHandler> = {
|
||||
approve_vacation: {
|
||||
permission: "approveVacations",
|
||||
execute: async (vacationId, db, _executorId) => {
|
||||
const vacation = await db.vacation.findUnique({
|
||||
where: { id: vacationId },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
if (!vacation) return { success: false, message: "Vacation not found" };
|
||||
if (vacation.status !== "PENDING") {
|
||||
return { success: false, message: `Vacation is ${vacation.status}, not PENDING` };
|
||||
}
|
||||
await db.vacation.update({
|
||||
where: { id: vacationId },
|
||||
data: { status: "APPROVED" },
|
||||
});
|
||||
return { success: true, message: "Vacation approved" };
|
||||
},
|
||||
},
|
||||
|
||||
reject_vacation: {
|
||||
permission: "approveVacations",
|
||||
execute: async (vacationId, db, _executorId) => {
|
||||
const vacation = await db.vacation.findUnique({
|
||||
where: { id: vacationId },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
if (!vacation) return { success: false, message: "Vacation not found" };
|
||||
if (vacation.status !== "PENDING") {
|
||||
return { success: false, message: `Vacation is ${vacation.status}, not PENDING` };
|
||||
}
|
||||
await db.vacation.update({
|
||||
where: { id: vacationId },
|
||||
data: { status: "REJECTED" },
|
||||
});
|
||||
return { success: true, message: "Vacation rejected" };
|
||||
},
|
||||
},
|
||||
|
||||
confirm_assignment: {
|
||||
permission: "manageAllocations",
|
||||
execute: async (assignmentId, db, _executorId) => {
|
||||
const assignment = await db.assignment.findUnique({
|
||||
where: { id: assignmentId },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
if (!assignment) return { success: false, message: "Assignment not found" };
|
||||
if (assignment.status === "CONFIRMED") {
|
||||
return { success: false, message: "Assignment is already CONFIRMED" };
|
||||
}
|
||||
await db.assignment.update({
|
||||
where: { id: assignmentId },
|
||||
data: { status: "CONFIRMED" },
|
||||
});
|
||||
return { success: true, message: "Assignment confirmed" };
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function getTaskAction(actionName: string): TaskActionHandler | undefined {
|
||||
return TASK_ACTION_REGISTRY[actionName];
|
||||
}
|
||||
Reference in New Issue
Block a user