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:
@@ -7,7 +7,8 @@
|
||||
".": "./src/index.ts",
|
||||
"./router": "./src/router/index.ts",
|
||||
"./trpc": "./src/trpc.ts",
|
||||
"./sse": "./src/sse/event-bus.ts"
|
||||
"./sse": "./src/sse/event-bus.ts",
|
||||
"./lib/reminder-scheduler": "./src/lib/reminder-scheduler.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "@planarchy/application";
|
||||
import {
|
||||
AllocationStatus,
|
||||
buildTaskAction,
|
||||
CreateAllocationSchema,
|
||||
CreateAssignmentSchema,
|
||||
CreateDemandRequirementSchema,
|
||||
@@ -28,7 +29,7 @@ import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||
import { anonymizeResource, getAnonymizationDirectory } from "../lib/anonymization.js";
|
||||
import { emitAllocationCreated, emitAllocationDeleted, emitAllocationUpdated } from "../sse/event-bus.js";
|
||||
import { emitAllocationCreated, emitAllocationDeleted, emitAllocationUpdated, emitNotificationCreated } from "../sse/event-bus.js";
|
||||
import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
|
||||
import { PROJECT_BRIEF_SELECT, RESOURCE_BRIEF_SELECT, ROLE_BRIEF_SELECT } from "../db/selects.js";
|
||||
|
||||
@@ -445,6 +446,47 @@ export const allocationRouter = createTRPCRouter({
|
||||
resourceId: null,
|
||||
});
|
||||
|
||||
// Create staffing tasks for managers
|
||||
const [project, roleEntity, managers] = await Promise.all([
|
||||
ctx.db.project.findUnique({
|
||||
where: { id: demandRequirement.projectId },
|
||||
select: { name: true },
|
||||
}),
|
||||
demandRequirement.roleId
|
||||
? ctx.db.role.findUnique({
|
||||
where: { id: demandRequirement.roleId },
|
||||
select: { name: true },
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
ctx.db.user.findMany({
|
||||
where: { systemRole: { in: ["ADMIN", "MANAGER"] } },
|
||||
select: { id: true },
|
||||
}),
|
||||
]);
|
||||
const roleName = roleEntity?.name ?? demandRequirement.role ?? "Unspecified role";
|
||||
const projectName = project?.name ?? "Unknown project";
|
||||
const headcount = demandRequirement.headcount ?? 1;
|
||||
|
||||
for (const manager of managers) {
|
||||
const task = await ctx.db.notification.create({
|
||||
data: {
|
||||
userId: manager.id,
|
||||
category: "TASK",
|
||||
type: "DEMAND_FILL",
|
||||
priority: "NORMAL",
|
||||
title: `Staff demand: ${roleName} for ${projectName}`,
|
||||
body: `${headcount} ${roleName} needed for project ${projectName}`,
|
||||
taskStatus: "OPEN",
|
||||
taskAction: buildTaskAction("fill_demand", demandRequirement.id),
|
||||
entityId: demandRequirement.id,
|
||||
entityType: "demand",
|
||||
link: `/projects/${demandRequirement.projectId}`,
|
||||
channel: "in_app",
|
||||
},
|
||||
});
|
||||
emitNotificationCreated(manager.id, task.id);
|
||||
}
|
||||
|
||||
return demandRequirement;
|
||||
}),
|
||||
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -23,6 +23,9 @@ Deine Fähigkeiten:
|
||||
- Rollen, Clients, Org-Units erstellen/aktualisieren/löschen
|
||||
- Estimates erstellen, Rate Cards abrufen, Blueprints anzeigen
|
||||
- Notifications anzeigen, Dashboard-Details abrufen
|
||||
- Tasks einsehen, Status ändern, Tasks erledigen (approve vacation, confirm allocation, etc.)
|
||||
- Persönliche Erinnerungen anlegen (einmalig oder wiederkehrend)
|
||||
- Tasks für andere User erstellen, Broadcasts an Gruppen senden
|
||||
- Den User zu relevanten Seiten navigieren (Timeline, Dashboard, etc. mit Filtern)
|
||||
- Verfügbarkeit von Ressourcen prüfen, Kapazitäten suchen
|
||||
|
||||
@@ -75,6 +78,10 @@ const TOOL_PERMISSION_MAP: Record<string, string> = {
|
||||
reject_vacation: "manageVacations",
|
||||
cancel_vacation: "manageVacations",
|
||||
set_entitlement: "manageVacations",
|
||||
// Task management
|
||||
create_task_for_user: "manageProjects",
|
||||
send_broadcast: "manageProjects",
|
||||
execute_task_action: "manageAllocations",
|
||||
};
|
||||
|
||||
/** Tools that require cost visibility */
|
||||
@@ -153,7 +160,7 @@ export const assistantRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
// 5. Function calling loop
|
||||
const toolCtx: ToolContext = { db: ctx.db, userRole, permissions };
|
||||
const toolCtx: ToolContext = { db: ctx.db, userId: ctx.dbUser!.id, userRole, permissions };
|
||||
const collectedActions: ToolAction[] = [];
|
||||
|
||||
for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
|
||||
|
||||
@@ -1,26 +1,88 @@
|
||||
import { z } from "zod";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { createTRPCRouter, managerProcedure, protectedProcedure } from "../trpc.js";
|
||||
import { emitNotificationCreated } from "../sse/event-bus.js";
|
||||
import {
|
||||
emitNotificationCreated,
|
||||
emitTaskAssigned,
|
||||
emitTaskCompleted,
|
||||
emitTaskStatusChanged,
|
||||
emitBroadcastSent,
|
||||
} from "../sse/event-bus.js";
|
||||
import { resolveRecipients } from "../lib/notification-targeting.js";
|
||||
import { sendEmail } from "../lib/email.js";
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Resolve the DB user id from the session email. Throws UNAUTHORIZED if not found. */
|
||||
async function resolveUserId(ctx: {
|
||||
db: { user: { findUnique: (args: { where: { email: string }; select: { id: true } }) => Promise<{ id: string } | null> } };
|
||||
db: {
|
||||
user: {
|
||||
findUnique: (args: {
|
||||
where: { email: string };
|
||||
select: { id: true };
|
||||
}) => Promise<{ id: string } | null>;
|
||||
};
|
||||
};
|
||||
session: { user?: { email?: string | null } | null };
|
||||
}): Promise<string> {
|
||||
const email = ctx.session.user?.email;
|
||||
if (!email) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
const user = await ctx.db.user.findUnique({ where: { email }, select: { id: true } });
|
||||
const user = await ctx.db.user.findUnique({
|
||||
where: { email },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!user) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
return user.id;
|
||||
}
|
||||
|
||||
/** Send email notification (non-blocking). */
|
||||
async function sendNotificationEmail(
|
||||
db: { user: { findUnique: (args: { where: { id: string }; select: { email: true; name: true } }) => Promise<{ email: string; name: string | null } | null> } },
|
||||
userId: string,
|
||||
title: string,
|
||||
body?: string | null,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { email: true, name: true },
|
||||
});
|
||||
if (!user) return;
|
||||
void sendEmail({
|
||||
to: user.email,
|
||||
subject: title,
|
||||
text: body ?? title,
|
||||
...(body !== undefined && body !== null ? { html: `<p>${body}</p>` } : {}),
|
||||
});
|
||||
} catch {
|
||||
// Non-blocking — swallow errors
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Zod Enums ────────────────────────────────────────────────────────────────
|
||||
|
||||
const categoryEnum = z.enum(["NOTIFICATION", "REMINDER", "TASK", "APPROVAL"]);
|
||||
const priorityEnum = z.enum(["LOW", "NORMAL", "HIGH", "URGENT"]);
|
||||
const taskStatusEnum = z.enum(["OPEN", "IN_PROGRESS", "DONE", "DISMISSED"]);
|
||||
const channelEnum = z.enum(["in_app", "email", "both"]);
|
||||
const recurrenceEnum = z.enum(["daily", "weekly", "monthly"]);
|
||||
const targetTypeEnum = z.enum(["user", "role", "project", "orgUnit", "all"]);
|
||||
|
||||
// ─── Router ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const notificationRouter = createTRPCRouter({
|
||||
/** List notifications for the current user */
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// EXISTING (enhanced)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** List notifications for the current user with optional filters */
|
||||
list: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
unreadOnly: z.boolean().optional(),
|
||||
category: categoryEnum.optional(),
|
||||
taskStatus: taskStatusEnum.optional(),
|
||||
priority: priorityEnum.optional(),
|
||||
limit: z.number().min(1).max(100).default(50),
|
||||
}),
|
||||
)
|
||||
@@ -30,6 +92,9 @@ export const notificationRouter = createTRPCRouter({
|
||||
where: {
|
||||
userId,
|
||||
...(input.unreadOnly ? { readAt: null } : {}),
|
||||
...(input.category !== undefined ? { category: input.category } : {}),
|
||||
...(input.taskStatus !== undefined ? { taskStatus: input.taskStatus } : {}),
|
||||
...(input.priority !== undefined ? { priority: input.priority } : {}),
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: input.limit,
|
||||
@@ -63,7 +128,7 @@ export const notificationRouter = createTRPCRouter({
|
||||
}
|
||||
}),
|
||||
|
||||
/** Create a notification — restricted to managers and admins */
|
||||
/** Create a notification (enhanced with all new fields) */
|
||||
create: managerProcedure
|
||||
.input(
|
||||
z.object({
|
||||
@@ -73,9 +138,21 @@ export const notificationRouter = createTRPCRouter({
|
||||
body: z.string().optional(),
|
||||
entityId: z.string().optional(),
|
||||
entityType: z.string().optional(),
|
||||
// New fields
|
||||
category: categoryEnum.optional(),
|
||||
priority: priorityEnum.optional(),
|
||||
link: z.string().optional(),
|
||||
taskStatus: taskStatusEnum.optional(),
|
||||
taskAction: z.string().optional(),
|
||||
assigneeId: z.string().optional(),
|
||||
dueDate: z.date().optional(),
|
||||
channel: channelEnum.optional(),
|
||||
senderId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const currentUserId = ctx.dbUser.id;
|
||||
|
||||
const n = await ctx.db.notification.create({
|
||||
data: {
|
||||
userId: input.userId,
|
||||
@@ -84,9 +161,489 @@ export const notificationRouter = createTRPCRouter({
|
||||
...(input.body !== undefined ? { body: input.body } : {}),
|
||||
...(input.entityId !== undefined ? { entityId: input.entityId } : {}),
|
||||
...(input.entityType !== undefined ? { entityType: input.entityType } : {}),
|
||||
...(input.category !== undefined ? { category: input.category } : {}),
|
||||
...(input.priority !== undefined ? { priority: input.priority } : {}),
|
||||
...(input.link !== undefined ? { link: input.link } : {}),
|
||||
...(input.taskStatus !== undefined ? { taskStatus: input.taskStatus } : {}),
|
||||
...(input.taskAction !== undefined ? { taskAction: input.taskAction } : {}),
|
||||
...(input.assigneeId !== undefined ? { assigneeId: input.assigneeId } : {}),
|
||||
...(input.dueDate !== undefined ? { dueDate: input.dueDate } : {}),
|
||||
...(input.channel !== undefined ? { channel: input.channel } : {}),
|
||||
senderId: input.senderId ?? currentUserId,
|
||||
},
|
||||
});
|
||||
|
||||
emitNotificationCreated(input.userId, n.id);
|
||||
|
||||
// Emit task-specific events
|
||||
if (input.category === "TASK" || input.category === "APPROVAL") {
|
||||
emitTaskAssigned(input.userId, n.id);
|
||||
}
|
||||
|
||||
// Email if channel includes email
|
||||
const channel = input.channel ?? "in_app";
|
||||
if (channel === "email" || channel === "both") {
|
||||
void sendNotificationEmail(ctx.db, input.userId, input.title, input.body);
|
||||
}
|
||||
|
||||
return n;
|
||||
}),
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// TASK MANAGEMENT
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** List tasks for the current user (as owner or assignee) */
|
||||
listTasks: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
status: taskStatusEnum.optional(),
|
||||
includeAssigned: z.boolean().default(true),
|
||||
limit: z.number().min(1).max(100).default(20),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const userId = await resolveUserId(ctx);
|
||||
|
||||
const userFilter = input.includeAssigned
|
||||
? { OR: [{ userId }, { assigneeId: userId }] }
|
||||
: { userId };
|
||||
|
||||
return ctx.db.notification.findMany({
|
||||
where: {
|
||||
...userFilter,
|
||||
category: { in: ["TASK", "APPROVAL"] },
|
||||
...(input.status !== undefined ? { taskStatus: input.status } : {}),
|
||||
},
|
||||
orderBy: [{ priority: "desc" }, { dueDate: "asc" }, { createdAt: "desc" }],
|
||||
take: input.limit,
|
||||
});
|
||||
}),
|
||||
|
||||
/** Get task counts for the current user */
|
||||
taskCounts: protectedProcedure.query(async ({ ctx }) => {
|
||||
const userId = await resolveUserId(ctx);
|
||||
const now = new Date();
|
||||
|
||||
const where = {
|
||||
OR: [{ userId }, { assigneeId: userId }],
|
||||
category: { in: ["TASK" as const, "APPROVAL" as const] },
|
||||
};
|
||||
|
||||
const [open, inProgress, done, dismissed, overdue] = await Promise.all([
|
||||
ctx.db.notification.count({ where: { ...where, taskStatus: "OPEN" } }),
|
||||
ctx.db.notification.count({ where: { ...where, taskStatus: "IN_PROGRESS" } }),
|
||||
ctx.db.notification.count({ where: { ...where, taskStatus: "DONE" } }),
|
||||
ctx.db.notification.count({ where: { ...where, taskStatus: "DISMISSED" } }),
|
||||
ctx.db.notification.count({
|
||||
where: {
|
||||
...where,
|
||||
taskStatus: { in: ["OPEN", "IN_PROGRESS"] },
|
||||
dueDate: { lt: now },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return { open, inProgress, done, dismissed, overdue };
|
||||
}),
|
||||
|
||||
/** Update task status */
|
||||
updateTaskStatus: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
status: taskStatusEnum,
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = await resolveUserId(ctx);
|
||||
|
||||
// Only allow if userId or assigneeId matches
|
||||
const existing = await ctx.db.notification.findFirst({
|
||||
where: {
|
||||
id: input.id,
|
||||
OR: [{ userId }, { assigneeId: userId }],
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Task not found or you do not have permission",
|
||||
});
|
||||
}
|
||||
|
||||
const isCompleting = input.status === "DONE";
|
||||
|
||||
const updated = await ctx.db.notification.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
taskStatus: input.status,
|
||||
...(isCompleting ? { completedAt: new Date(), completedBy: userId } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (isCompleting) {
|
||||
emitTaskCompleted(existing.userId, updated.id);
|
||||
// Also notify assignee if different
|
||||
if (existing.assigneeId && existing.assigneeId !== existing.userId) {
|
||||
emitTaskCompleted(existing.assigneeId, updated.id);
|
||||
}
|
||||
} else {
|
||||
emitTaskStatusChanged(existing.userId, updated.id);
|
||||
if (existing.assigneeId && existing.assigneeId !== existing.userId) {
|
||||
emitTaskStatusChanged(existing.assigneeId, updated.id);
|
||||
}
|
||||
}
|
||||
|
||||
return updated;
|
||||
}),
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// REMINDERS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** Create a personal reminder */
|
||||
createReminder: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
body: z.string().max(2000).optional(),
|
||||
remindAt: z.date(),
|
||||
recurrence: recurrenceEnum.optional(),
|
||||
entityId: z.string().optional(),
|
||||
entityType: z.string().optional(),
|
||||
link: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = await resolveUserId(ctx);
|
||||
|
||||
return ctx.db.notification.create({
|
||||
data: {
|
||||
userId,
|
||||
type: "REMINDER",
|
||||
category: "REMINDER",
|
||||
title: input.title,
|
||||
...(input.body !== undefined ? { body: input.body } : {}),
|
||||
remindAt: input.remindAt,
|
||||
nextRemindAt: input.remindAt,
|
||||
...(input.recurrence !== undefined ? { recurrence: input.recurrence } : {}),
|
||||
...(input.entityId !== undefined ? { entityId: input.entityId } : {}),
|
||||
...(input.entityType !== undefined ? { entityType: input.entityType } : {}),
|
||||
...(input.link !== undefined ? { link: input.link } : {}),
|
||||
channel: "in_app",
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
/** Update a personal reminder */
|
||||
updateReminder: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
body: z.string().max(2000).optional(),
|
||||
remindAt: z.date().optional(),
|
||||
recurrence: recurrenceEnum.nullish(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = await resolveUserId(ctx);
|
||||
|
||||
// Verify ownership
|
||||
const existing = await ctx.db.notification.findFirst({
|
||||
where: { id: input.id, userId, category: "REMINDER" },
|
||||
});
|
||||
if (!existing) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Reminder not found or you do not have permission",
|
||||
});
|
||||
}
|
||||
|
||||
return ctx.db.notification.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
...(input.title !== undefined ? { title: input.title } : {}),
|
||||
...(input.body !== undefined ? { body: input.body } : {}),
|
||||
...(input.remindAt !== undefined
|
||||
? { remindAt: input.remindAt, nextRemindAt: input.remindAt }
|
||||
: {}),
|
||||
// recurrence can be set to null (clear it) or a new value
|
||||
...(input.recurrence !== undefined
|
||||
? { recurrence: input.recurrence }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
/** Delete a personal reminder */
|
||||
deleteReminder: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = await resolveUserId(ctx);
|
||||
|
||||
const existing = await ctx.db.notification.findFirst({
|
||||
where: { id: input.id, userId, category: "REMINDER" },
|
||||
});
|
||||
if (!existing) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Reminder not found or you do not have permission",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.notification.delete({ where: { id: input.id } });
|
||||
}),
|
||||
|
||||
/** List personal reminders */
|
||||
listReminders: protectedProcedure
|
||||
.input(z.object({ limit: z.number().min(1).max(100).default(20) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const userId = await resolveUserId(ctx);
|
||||
|
||||
return ctx.db.notification.findMany({
|
||||
where: { userId, category: "REMINDER" },
|
||||
orderBy: { nextRemindAt: "asc" },
|
||||
take: input.limit,
|
||||
});
|
||||
}),
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// BROADCASTS (Manager+)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** Create and send a broadcast notification */
|
||||
createBroadcast: managerProcedure
|
||||
.input(
|
||||
z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
body: z.string().max(2000).optional(),
|
||||
link: z.string().optional(),
|
||||
category: categoryEnum.default("NOTIFICATION"),
|
||||
priority: priorityEnum.default("NORMAL"),
|
||||
channel: channelEnum.default("in_app"),
|
||||
targetType: targetTypeEnum,
|
||||
targetValue: z.string().optional(),
|
||||
scheduledAt: z.date().optional(),
|
||||
taskAction: z.string().optional(),
|
||||
dueDate: z.date().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const senderId = ctx.dbUser.id;
|
||||
|
||||
// 1. Create broadcast record
|
||||
const broadcast = await ctx.db.notificationBroadcast.create({
|
||||
data: {
|
||||
senderId,
|
||||
title: input.title,
|
||||
...(input.body !== undefined ? { body: input.body } : {}),
|
||||
...(input.link !== undefined ? { link: input.link } : {}),
|
||||
category: input.category,
|
||||
priority: input.priority,
|
||||
channel: input.channel,
|
||||
targetType: input.targetType,
|
||||
...(input.targetValue !== undefined ? { targetValue: input.targetValue } : {}),
|
||||
...(input.scheduledAt !== undefined ? { scheduledAt: input.scheduledAt } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
// 2. If scheduled in the future, just return the broadcast
|
||||
if (input.scheduledAt && input.scheduledAt > new Date()) {
|
||||
return broadcast;
|
||||
}
|
||||
|
||||
// 3. Resolve recipients
|
||||
const recipientIds = await resolveRecipients(
|
||||
input.targetType,
|
||||
input.targetValue,
|
||||
ctx.db,
|
||||
senderId,
|
||||
);
|
||||
|
||||
// 4. Create individual notifications for each recipient
|
||||
const isTask = input.category === "TASK" || input.category === "APPROVAL";
|
||||
|
||||
const notifications = await Promise.all(
|
||||
recipientIds.map((recipientUserId) =>
|
||||
ctx.db.notification.create({
|
||||
data: {
|
||||
userId: recipientUserId,
|
||||
type: `BROADCAST_${input.category}`,
|
||||
title: input.title,
|
||||
...(input.body !== undefined ? { body: input.body } : {}),
|
||||
...(input.link !== undefined ? { link: input.link } : {}),
|
||||
category: input.category,
|
||||
priority: input.priority,
|
||||
channel: input.channel,
|
||||
sourceId: broadcast.id,
|
||||
senderId,
|
||||
...(isTask ? { taskStatus: "OPEN" as const } : {}),
|
||||
...(input.taskAction !== undefined ? { taskAction: input.taskAction } : {}),
|
||||
...(input.dueDate !== undefined ? { dueDate: input.dueDate } : {}),
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// 5. Update broadcast with sent info
|
||||
await ctx.db.notificationBroadcast.update({
|
||||
where: { id: broadcast.id },
|
||||
data: {
|
||||
sentAt: new Date(),
|
||||
recipientCount: notifications.length,
|
||||
},
|
||||
});
|
||||
|
||||
// 6. Emit SSE events
|
||||
for (const n of notifications) {
|
||||
emitNotificationCreated(n.userId, n.id);
|
||||
if (isTask) {
|
||||
emitTaskAssigned(n.userId, n.id);
|
||||
}
|
||||
}
|
||||
emitBroadcastSent(broadcast.id, notifications.length);
|
||||
|
||||
// 7. Send emails if channel includes email (non-blocking)
|
||||
if (input.channel === "email" || input.channel === "both") {
|
||||
for (const n of notifications) {
|
||||
void sendNotificationEmail(ctx.db, n.userId, input.title, input.body);
|
||||
}
|
||||
}
|
||||
|
||||
return { ...broadcast, recipientCount: notifications.length, sentAt: new Date() };
|
||||
}),
|
||||
|
||||
/** List broadcasts */
|
||||
listBroadcasts: managerProcedure
|
||||
.input(z.object({ limit: z.number().min(1).max(50).default(20) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return ctx.db.notificationBroadcast.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: input.limit,
|
||||
include: {
|
||||
sender: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// TASK CREATION (Manager+)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** Create a task for a specific user */
|
||||
createTask: managerProcedure
|
||||
.input(
|
||||
z.object({
|
||||
userId: z.string(),
|
||||
title: z.string().min(1).max(200),
|
||||
body: z.string().max(2000).optional(),
|
||||
priority: priorityEnum.default("NORMAL"),
|
||||
dueDate: z.date().optional(),
|
||||
taskAction: z.string().optional(),
|
||||
entityId: z.string().optional(),
|
||||
entityType: z.string().optional(),
|
||||
link: z.string().optional(),
|
||||
channel: channelEnum.default("in_app"),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const senderId = ctx.dbUser.id;
|
||||
|
||||
const n = await ctx.db.notification.create({
|
||||
data: {
|
||||
userId: input.userId,
|
||||
type: "TASK_CREATED",
|
||||
category: "TASK",
|
||||
taskStatus: "OPEN",
|
||||
title: input.title,
|
||||
priority: input.priority,
|
||||
senderId,
|
||||
channel: input.channel,
|
||||
...(input.body !== undefined ? { body: input.body } : {}),
|
||||
...(input.dueDate !== undefined ? { dueDate: input.dueDate } : {}),
|
||||
...(input.taskAction !== undefined ? { taskAction: input.taskAction } : {}),
|
||||
...(input.entityId !== undefined ? { entityId: input.entityId } : {}),
|
||||
...(input.entityType !== undefined ? { entityType: input.entityType } : {}),
|
||||
...(input.link !== undefined ? { link: input.link } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
emitNotificationCreated(input.userId, n.id);
|
||||
emitTaskAssigned(input.userId, n.id);
|
||||
|
||||
// Send email if channel includes email
|
||||
if (input.channel === "email" || input.channel === "both") {
|
||||
void sendNotificationEmail(ctx.db, input.userId, input.title, input.body);
|
||||
}
|
||||
|
||||
return n;
|
||||
}),
|
||||
|
||||
/** Reassign a task to another user */
|
||||
assignTask: managerProcedure
|
||||
.input(z.object({ id: z.string(), assigneeId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.db.notification.findUnique({
|
||||
where: { id: input.id },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Task not found" });
|
||||
}
|
||||
|
||||
if (existing.category !== "TASK" && existing.category !== "APPROVAL") {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Only tasks and approvals can be assigned",
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await ctx.db.notification.update({
|
||||
where: { id: input.id },
|
||||
data: { assigneeId: input.assigneeId },
|
||||
});
|
||||
|
||||
emitTaskAssigned(input.assigneeId, updated.id);
|
||||
|
||||
return updated;
|
||||
}),
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// DELETE
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** Delete own notification */
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ id: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = await resolveUserId(ctx);
|
||||
|
||||
const existing = await ctx.db.notification.findFirst({
|
||||
where: { id: input.id, userId },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Notification not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Cannot delete tasks created by others (senderId differs)
|
||||
if (
|
||||
(existing.category === "TASK" || existing.category === "APPROVAL") &&
|
||||
existing.senderId &&
|
||||
existing.senderId !== userId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Cannot delete tasks created by others",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.notification.delete({ where: { id: input.id } });
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -209,3 +209,23 @@ export const emitRoleDeleted = (roleId: string) =>
|
||||
export function emitNotificationCreated(userId: string, notificationId: string): void {
|
||||
eventBus.emit(SSE_EVENT_TYPES.NOTIFICATION_CREATED, { userId, notificationId });
|
||||
}
|
||||
|
||||
export function emitTaskAssigned(userId: string, notificationId: string): void {
|
||||
eventBus.emit(SSE_EVENT_TYPES.TASK_ASSIGNED, { userId, notificationId });
|
||||
}
|
||||
|
||||
export function emitTaskCompleted(userId: string, notificationId: string): void {
|
||||
eventBus.emit(SSE_EVENT_TYPES.TASK_COMPLETED, { userId, notificationId });
|
||||
}
|
||||
|
||||
export function emitTaskStatusChanged(userId: string, notificationId: string): void {
|
||||
eventBus.emit(SSE_EVENT_TYPES.TASK_STATUS_CHANGED, { userId, notificationId });
|
||||
}
|
||||
|
||||
export function emitReminderDue(userId: string, notificationId: string): void {
|
||||
eventBus.emit(SSE_EVENT_TYPES.REMINDER_DUE, { userId, notificationId });
|
||||
}
|
||||
|
||||
export function emitBroadcastSent(broadcastId: string, recipientCount: number): void {
|
||||
eventBus.emit(SSE_EVENT_TYPES.BROADCAST_SENT, { broadcastId, recipientCount });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user