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,5 @@
|
|||||||
|
import { BroadcastManagementClient } from "~/components/notifications/BroadcastManagementClient.js";
|
||||||
|
|
||||||
|
export default function AdminNotificationsPage() {
|
||||||
|
return <BroadcastManagementClient />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
|
import { NotificationCenterClient } from "~/components/notifications/NotificationCenterClient.js";
|
||||||
|
|
||||||
|
export default function NotificationsPage() {
|
||||||
|
return (
|
||||||
|
<Suspense>
|
||||||
|
<NotificationCenterClient />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import { eventBus } from "@planarchy/api/sse";
|
import { eventBus } from "@planarchy/api/sse";
|
||||||
|
import { startReminderScheduler } from "@planarchy/api/lib/reminder-scheduler";
|
||||||
import { SSE_EVENT_TYPES } from "@planarchy/shared";
|
import { SSE_EVENT_TYPES } from "@planarchy/shared";
|
||||||
import { auth } from "~/server/auth.js";
|
import { auth } from "~/server/auth.js";
|
||||||
|
|
||||||
|
// Start the reminder scheduler (idempotent — only starts once)
|
||||||
|
startReminderScheduler();
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { Route } from "next";
|
||||||
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
||||||
|
import { TaskCard } from "~/components/notifications/TaskCard.js";
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
export function TaskWidget(_props: Partial<WidgetProps> = {}) {
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
|
const { data: tasks, isLoading: loadingTasks } = trpc.notification.listTasks.useQuery(
|
||||||
|
{ status: "OPEN", limit: 5 },
|
||||||
|
{ staleTime: 30_000, placeholderData: (prev) => prev },
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: taskCounts, isLoading: loadingCounts } = trpc.notification.taskCounts.useQuery(undefined, {
|
||||||
|
staleTime: 30_000,
|
||||||
|
placeholderData: (prev) => prev,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateTaskStatus = trpc.notification.updateTaskStatus.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.notification.taskCounts.invalidate();
|
||||||
|
void utils.notification.listTasks.invalidate();
|
||||||
|
void utils.notification.list.invalidate();
|
||||||
|
void utils.notification.unreadCount.invalidate();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleStatusChange(id: string, status: string) {
|
||||||
|
updateTaskStatus.mutate({ id, status: status as "OPEN" | "IN_PROGRESS" | "DONE" | "DISMISSED" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCount = (taskCounts?.open ?? 0) + (taskCounts?.inProgress ?? 0);
|
||||||
|
const isLoading = loadingTasks || loadingCounts;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="flex items-center justify-between px-1 pb-3">
|
||||||
|
<div className="h-5 w-32 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
{[...Array(3)].map((_, i) => (
|
||||||
|
<div key={i} className="animate-pulse rounded-xl border border-gray-200 dark:border-gray-700 p-3">
|
||||||
|
<div className="h-4 w-3/4 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||||
|
<div className="mt-2 h-3 w-1/2 bg-gray-100 dark:bg-gray-800 rounded" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-1 pb-3">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-50">
|
||||||
|
Open Tasks
|
||||||
|
{openCount > 0 && (
|
||||||
|
<span className="ml-2 inline-flex items-center justify-center min-w-[18px] h-5 px-1.5 text-[10px] font-bold text-white bg-orange-500 rounded-full">
|
||||||
|
{openCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
{taskCounts?.overdue !== undefined && taskCounts.overdue > 0 && (
|
||||||
|
<span className="text-xs font-semibold text-red-600 dark:text-red-400">
|
||||||
|
{taskCounts.overdue} overdue
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 space-y-2 overflow-y-auto">
|
||||||
|
{!tasks || tasks.length === 0 ? (
|
||||||
|
<div className="flex h-full items-center justify-center rounded-xl border border-dashed border-gray-300 dark:border-gray-600 py-8 text-sm text-gray-400 dark:text-gray-500">
|
||||||
|
No open tasks
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
tasks.map((t) => (
|
||||||
|
<TaskCard
|
||||||
|
key={t.id}
|
||||||
|
task={{
|
||||||
|
id: t.id,
|
||||||
|
title: t.title,
|
||||||
|
body: t.body,
|
||||||
|
priority: t.priority ?? "NORMAL",
|
||||||
|
taskStatus: t.taskStatus,
|
||||||
|
taskAction: t.taskAction,
|
||||||
|
dueDate: t.dueDate,
|
||||||
|
entityType: t.entityType,
|
||||||
|
link: t.link,
|
||||||
|
createdAt: t.createdAt,
|
||||||
|
completedBy: t.completedBy,
|
||||||
|
}}
|
||||||
|
onStatusChange={handleStatusChange}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="pt-2 border-t border-gray-100 dark:border-gray-800 mt-2">
|
||||||
|
<Link
|
||||||
|
href={"/notifications?tab=tasks" as Route}
|
||||||
|
className="block text-center text-xs font-medium text-brand-600 dark:text-brand-400 hover:underline"
|
||||||
|
>
|
||||||
|
View all →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -57,6 +57,12 @@ function ChargeabilityIcon() {
|
|||||||
function GraphIcon() {
|
function GraphIcon() {
|
||||||
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle cx="6" cy="6" r="2.5" strokeWidth={1.8} /><circle cx="18" cy="6" r="2.5" strokeWidth={1.8} /><circle cx="12" cy="18" r="2.5" strokeWidth={1.8} /><path strokeLinecap="round" strokeWidth={1.8} d="M8.5 7.5l2 7M15.5 7.5l-2 7M8.5 6h7" /></svg>;
|
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle cx="6" cy="6" r="2.5" strokeWidth={1.8} /><circle cx="18" cy="6" r="2.5" strokeWidth={1.8} /><circle cx="12" cy="18" r="2.5" strokeWidth={1.8} /><path strokeLinecap="round" strokeWidth={1.8} d="M8.5 7.5l2 7M15.5 7.5l-2 7M8.5 6h7" /></svg>;
|
||||||
}
|
}
|
||||||
|
function NotificationsIcon() {
|
||||||
|
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6 6 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /></svg>;
|
||||||
|
}
|
||||||
|
function BroadcastIcon() {
|
||||||
|
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" /></svg>;
|
||||||
|
}
|
||||||
function AdminIcon() {
|
function AdminIcon() {
|
||||||
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M12 8a4 4 0 100 8 4 4 0 000-8zm8 4l-2.1.7a7.9 7.9 0 01-.6 1.5l1 2-2.1 2.1-2-1a7.9 7.9 0 01-1.5.6L12 20l-1.7-2.1a7.9 7.9 0 01-1.5-.6l-2 1-2.1-2.1 1-2a7.9 7.9 0 01-.6-1.5L4 12l2.1-1.7a7.9 7.9 0 01.6-1.5l-1-2 2.1-2.1 2 1a7.9 7.9 0 011.5-.6L12 4l1.7 2.1a7.9 7.9 0 011.5.6l2-1 2.1 2.1-1 2a7.9 7.9 0 01.6 1.5L20 12z" /></svg>;
|
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M12 8a4 4 0 100 8 4 4 0 000-8zm8 4l-2.1.7a7.9 7.9 0 01-.6 1.5l1 2-2.1 2.1-2-1a7.9 7.9 0 01-1.5.6L12 20l-1.7-2.1a7.9 7.9 0 01-1.5-.6l-2 1-2.1-2.1 1-2a7.9 7.9 0 01-.6-1.5L4 12l2.1-1.7a7.9 7.9 0 01.6-1.5l-1-2 2.1-2.1 2 1a7.9 7.9 0 011.5-.6L12 4l1.7 2.1a7.9 7.9 0 011.5.6l2-1 2.1 2.1-1 2a7.9 7.9 0 01.6 1.5L20 12z" /></svg>;
|
||||||
}
|
}
|
||||||
@@ -72,6 +78,7 @@ const navSections: NavSection[] = [
|
|||||||
{ href: "/timeline", label: "Timeline", icon: <TimelineIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
|
{ href: "/timeline", label: "Timeline", icon: <TimelineIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
|
||||||
{ href: "/allocations", label: "Allocations", icon: <AllocationsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
|
{ href: "/allocations", label: "Allocations", icon: <AllocationsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
|
||||||
{ href: "/staffing", label: "Staffing", icon: <StaffingIcon />, roles: ["ADMIN", "MANAGER"] },
|
{ href: "/staffing", label: "Staffing", icon: <StaffingIcon />, roles: ["ADMIN", "MANAGER"] },
|
||||||
|
{ href: "/notifications", label: "Notifications", icon: <NotificationsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -134,6 +141,7 @@ const adminNavEntries: AdminEntry[] = [
|
|||||||
{ href: "/admin/users", label: "Users", icon: <AdminIcon /> },
|
{ href: "/admin/users", label: "Users", icon: <AdminIcon /> },
|
||||||
{ href: "/admin/settings", label: "Settings", icon: <AdminIcon /> },
|
{ href: "/admin/settings", label: "Settings", icon: <AdminIcon /> },
|
||||||
{ href: "/admin/skill-import", label: "Skill Import", icon: <AdminIcon /> },
|
{ href: "/admin/skill-import", label: "Skill Import", icon: <AdminIcon /> },
|
||||||
|
{ href: "/admin/notifications", label: "Broadcasts", icon: <BroadcastIcon /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
import { BroadcastModal } from "./BroadcastModal.js";
|
||||||
|
|
||||||
|
function formatDate(date: string | Date): string {
|
||||||
|
const d = typeof date === "string" ? new Date(date) : date;
|
||||||
|
return d.toLocaleDateString("en-GB", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const TARGET_LABELS: Record<string, string> = {
|
||||||
|
all: "All Users",
|
||||||
|
role: "By Role",
|
||||||
|
project: "By Project",
|
||||||
|
orgUnit: "By Org Unit",
|
||||||
|
user: "Specific User",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BroadcastManagementClient() {
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
|
||||||
|
const { data: broadcasts = [], isLoading } = trpc.notification.listBroadcasts.useQuery(
|
||||||
|
{ limit: 50 },
|
||||||
|
{ staleTime: 30_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
|
function handleSuccess() {
|
||||||
|
void utils.notification.listBroadcasts.invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Broadcast Management</h1>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowModal(true)}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
Send Broadcast
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[...Array(3)].map((_, i) => (
|
||||||
|
<div key={i} className="animate-pulse rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<div className="h-4 w-1/2 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||||
|
<div className="mt-2 h-3 w-1/3 bg-gray-100 dark:bg-gray-800 rounded" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
{!isLoading && (
|
||||||
|
<>
|
||||||
|
{broadcasts.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-dashed border-gray-300 dark:border-gray-600 py-12 text-center text-sm text-gray-400 dark:text-gray-500">
|
||||||
|
No broadcasts sent yet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-hidden rounded-xl border border-gray-200 dark:border-gray-700">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||||
|
Title
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||||
|
Target
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||||
|
Recipients
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||||
|
Sender
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||||
|
Date
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-200 dark:divide-gray-700 bg-white dark:bg-gray-900">
|
||||||
|
{broadcasts.map((b) => (
|
||||||
|
<tr key={b.id} className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate max-w-[200px]">
|
||||||
|
{b.title}
|
||||||
|
</p>
|
||||||
|
{b.body && (
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 truncate max-w-[200px]">
|
||||||
|
{b.body}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{TARGET_LABELS[b.targetType] ?? b.targetType}
|
||||||
|
{b.targetValue && (
|
||||||
|
<span className="ml-1 text-gray-400 dark:text-gray-500">({b.targetValue})</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-right text-gray-600 dark:text-gray-300">
|
||||||
|
{b.recipientCount ?? 0}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
{b.sender?.name ?? b.sender?.email ?? "-"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{b.sentAt ? formatDate(b.sentAt) : "Scheduled"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Broadcast Modal */}
|
||||||
|
{showModal && (
|
||||||
|
<BroadcastModal
|
||||||
|
onClose={() => setShowModal(false)}
|
||||||
|
onSuccess={handleSuccess}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
|
||||||
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
|
||||||
|
const TARGET_TYPES = [
|
||||||
|
{ value: "all", label: "All Users" },
|
||||||
|
{ value: "role", label: "By Role" },
|
||||||
|
{ value: "project", label: "By Project" },
|
||||||
|
{ value: "orgUnit", label: "By Org Unit" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const ROLES = ["ADMIN", "MANAGER", "CONTROLLER", "USER", "VIEWER"] as const;
|
||||||
|
|
||||||
|
const PRIORITY_OPTIONS = [
|
||||||
|
{ value: "LOW", label: "Low" },
|
||||||
|
{ value: "NORMAL", label: "Normal" },
|
||||||
|
{ value: "HIGH", label: "High" },
|
||||||
|
{ value: "URGENT", label: "Urgent" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const CHANNEL_OPTIONS = [
|
||||||
|
{ value: "in_app", label: "In-App" },
|
||||||
|
{ value: "email", label: "Email" },
|
||||||
|
{ value: "both", label: "Both" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
interface BroadcastModalProps {
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BroadcastModal({ onClose, onSuccess }: BroadcastModalProps) {
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [body, setBody] = useState("");
|
||||||
|
const [targetType, setTargetType] = useState<string>("all");
|
||||||
|
const [targetValue, setTargetValue] = useState("");
|
||||||
|
const [priority, setPriority] = useState("NORMAL");
|
||||||
|
const [channel, setChannel] = useState("in_app");
|
||||||
|
const [link, setLink] = useState("");
|
||||||
|
const [serverError, setServerError] = useState<string | null>(null);
|
||||||
|
const [result, setResult] = useState<{ recipientCount: number } | null>(null);
|
||||||
|
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
useFocusTrap(panelRef, true);
|
||||||
|
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
|
const createMutation = trpc.notification.createBroadcast.useMutation({
|
||||||
|
onSuccess: async (data) => {
|
||||||
|
await utils.notification.listBroadcasts.invalidate();
|
||||||
|
const count = (data as { recipientCount?: number }).recipientCount ?? 0;
|
||||||
|
setResult({ recipientCount: count });
|
||||||
|
},
|
||||||
|
onError: (err) => setServerError(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const isPending = createMutation.isPending;
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setServerError(null);
|
||||||
|
|
||||||
|
if (!title.trim()) {
|
||||||
|
setServerError("Title is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
createMutation.mutate({
|
||||||
|
title: title.trim(),
|
||||||
|
...(body.trim() ? { body: body.trim() } : {}),
|
||||||
|
targetType: targetType as "all" | "role" | "project" | "orgUnit" | "user",
|
||||||
|
...(targetType !== "all" && targetValue.trim() ? { targetValue: targetValue.trim() } : {}),
|
||||||
|
priority: priority as "LOW" | "NORMAL" | "HIGH" | "URGENT",
|
||||||
|
channel: channel as "in_app" | "email" | "both",
|
||||||
|
...(link.trim() ? { link: link.trim() } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100";
|
||||||
|
const labelClass = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
|
||||||
|
|
||||||
|
// After successful send, show result
|
||||||
|
if (result) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) { onSuccess(); onClose(); }
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-lg mx-4"
|
||||||
|
onKeyDown={(e) => { if (e.key === "Escape") { onSuccess(); onClose(); } }}
|
||||||
|
>
|
||||||
|
<div className="px-6 py-8 text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
|
||||||
|
<svg className="h-6 w-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Broadcast Sent</h3>
|
||||||
|
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Sent to {result.recipientCount} recipient{result.recipientCount !== 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { onSuccess(); onClose(); }}
|
||||||
|
className="mt-6 px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-lg mx-4"
|
||||||
|
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Send Broadcast</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors text-xl leading-none"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="px-6 py-5 space-y-4">
|
||||||
|
{/* Title */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="bc-title" className={labelClass}>
|
||||||
|
Title <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="bc-title"
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
maxLength={200}
|
||||||
|
className={inputClass}
|
||||||
|
required
|
||||||
|
placeholder="Broadcast title..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="bc-body" className={labelClass}>
|
||||||
|
Message (optional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="bc-body"
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
maxLength={2000}
|
||||||
|
className={`${inputClass} resize-none`}
|
||||||
|
placeholder="Message body..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Target type */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="bc-target" className={labelClass}>
|
||||||
|
Target Audience
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="bc-target"
|
||||||
|
value={targetType}
|
||||||
|
onChange={(e) => { setTargetType(e.target.value); setTargetValue(""); }}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
{TARGET_TYPES.map((t) => (
|
||||||
|
<option key={t.value} value={t.value}>{t.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Target value selector */}
|
||||||
|
{targetType === "role" && (
|
||||||
|
<div>
|
||||||
|
<label htmlFor="bc-role" className={labelClass}>
|
||||||
|
Role
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="bc-role"
|
||||||
|
value={targetValue}
|
||||||
|
onChange={(e) => setTargetValue(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
<option value="">Select a role...</option>
|
||||||
|
{ROLES.map((r) => (
|
||||||
|
<option key={r} value={r}>{r}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{targetType === "project" && (
|
||||||
|
<div>
|
||||||
|
<label htmlFor="bc-project" className={labelClass}>
|
||||||
|
Project ID
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="bc-project"
|
||||||
|
type="text"
|
||||||
|
value={targetValue}
|
||||||
|
onChange={(e) => setTargetValue(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="Project ID..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{targetType === "orgUnit" && (
|
||||||
|
<div>
|
||||||
|
<label htmlFor="bc-orgunit" className={labelClass}>
|
||||||
|
Org Unit ID
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="bc-orgunit"
|
||||||
|
type="text"
|
||||||
|
value={targetValue}
|
||||||
|
onChange={(e) => setTargetValue(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="Org Unit ID..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Priority + Channel */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="bc-priority" className={labelClass}>
|
||||||
|
Priority
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="bc-priority"
|
||||||
|
value={priority}
|
||||||
|
onChange={(e) => setPriority(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
{PRIORITY_OPTIONS.map((p) => (
|
||||||
|
<option key={p.value} value={p.value}>{p.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="bc-channel" className={labelClass}>
|
||||||
|
Channel
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="bc-channel"
|
||||||
|
value={channel}
|
||||||
|
onChange={(e) => setChannel(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
{CHANNEL_OPTIONS.map((c) => (
|
||||||
|
<option key={c.value} value={c.value}>{c.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Link */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="bc-link" className={labelClass}>
|
||||||
|
Link (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="bc-link"
|
||||||
|
type="text"
|
||||||
|
value={link}
|
||||||
|
onChange={(e) => setLink(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="/some/page or https://..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Server error */}
|
||||||
|
{serverError && (
|
||||||
|
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700 dark:bg-red-900/20 dark:border-red-800 dark:text-red-300">
|
||||||
|
{serverError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-end gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isPending}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isPending ? "Sending..." : "Send Broadcast"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { Route } from "next";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
|
||||||
function relativeTime(date: Date): string {
|
function relativeTime(date: Date): string {
|
||||||
@@ -19,8 +21,11 @@ function relativeTime(date: Date): string {
|
|||||||
return `${months}mo ago`;
|
return `${months}mo ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TabKey = "all" | "tasks" | "reminders";
|
||||||
|
|
||||||
export function NotificationBell() {
|
export function NotificationBell() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState<TabKey>("all");
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const { data: session, status } = useSession();
|
const { data: session, status } = useSession();
|
||||||
const isAuthenticated = status === "authenticated" && !!session?.user?.email;
|
const isAuthenticated = status === "authenticated" && !!session?.user?.email;
|
||||||
@@ -33,9 +38,27 @@ export function NotificationBell() {
|
|||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: taskCounts } = trpc.notification.taskCounts.useQuery(undefined, {
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const openTaskCount = (taskCounts?.open ?? 0) + (taskCounts?.inProgress ?? 0);
|
||||||
|
|
||||||
const { data: notifications = [] } = trpc.notification.list.useQuery(
|
const { data: notifications = [] } = trpc.notification.list.useQuery(
|
||||||
{ limit: 20 },
|
{ limit: 20 },
|
||||||
{ enabled: open && isAuthenticated, retry: false },
|
{ enabled: open && isAuthenticated && activeTab === "all", retry: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: tasks = [] } = trpc.notification.listTasks.useQuery(
|
||||||
|
{ status: "OPEN", limit: 10 },
|
||||||
|
{ enabled: open && isAuthenticated && activeTab === "tasks", retry: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: reminders = [] } = trpc.notification.listReminders.useQuery(
|
||||||
|
{ limit: 10 },
|
||||||
|
{ enabled: open && isAuthenticated && activeTab === "reminders", retry: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
const markRead = trpc.notification.markRead.useMutation({
|
const markRead = trpc.notification.markRead.useMutation({
|
||||||
@@ -45,6 +68,15 @@ export function NotificationBell() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const updateTaskStatus = trpc.notification.updateTaskStatus.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.notification.taskCounts.invalidate();
|
||||||
|
void utils.notification.listTasks.invalidate();
|
||||||
|
void utils.notification.unreadCount.invalidate();
|
||||||
|
void utils.notification.list.invalidate();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Close dropdown on outside click
|
// Close dropdown on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -67,6 +99,16 @@ export function NotificationBell() {
|
|||||||
markRead.mutate({ id });
|
markRead.mutate({ id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleTaskAction(id: string, taskStatus: string) {
|
||||||
|
updateTaskStatus.mutate({ id, status: taskStatus as "OPEN" | "IN_PROGRESS" | "DONE" | "DISMISSED" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs: { key: TabKey; label: string }[] = [
|
||||||
|
{ key: "all", label: "All" },
|
||||||
|
{ key: "tasks", label: "Tasks" },
|
||||||
|
{ key: "reminders", label: "Reminders" },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className="relative">
|
<div ref={ref} className="relative">
|
||||||
{/* Bell button */}
|
{/* Bell button */}
|
||||||
@@ -90,22 +132,29 @@ export function NotificationBell() {
|
|||||||
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
{/* Unread notification badge (red) */}
|
||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && (
|
||||||
<span className="absolute top-1 right-1 flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-bold text-white bg-red-500 rounded-full leading-none">
|
<span className="absolute top-0.5 right-0.5 flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-bold text-white bg-red-500 rounded-full leading-none">
|
||||||
{unreadCount > 99 ? "99+" : unreadCount}
|
{unreadCount > 99 ? "99+" : unreadCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{/* Task count badge (orange) */}
|
||||||
|
{openTaskCount > 0 && (
|
||||||
|
<span className="absolute -bottom-0.5 right-0.5 flex items-center justify-center min-w-[14px] h-3.5 px-0.5 text-[9px] font-bold text-white bg-orange-500 rounded-full leading-none">
|
||||||
|
{openTaskCount > 99 ? "99+" : openTaskCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Dropdown panel */}
|
{/* Dropdown panel */}
|
||||||
{open && (
|
{open && (
|
||||||
<div className="absolute right-0 mt-2 w-80 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg z-50 overflow-hidden">
|
<div className="absolute right-0 mt-2 w-96 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl shadow-lg z-50 overflow-hidden">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-gray-800">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-gray-800">
|
||||||
<span className="text-sm font-semibold text-gray-900 dark:text-gray-50">
|
<span className="text-sm font-semibold text-gray-900 dark:text-gray-50">
|
||||||
Notifications
|
Notifications
|
||||||
</span>
|
</span>
|
||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && activeTab === "all" && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleMarkAllRead}
|
onClick={handleMarkAllRead}
|
||||||
@@ -117,48 +166,174 @@ export function NotificationBell() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* List */}
|
{/* Tabs */}
|
||||||
<div className="max-h-96 overflow-y-auto divide-y divide-gray-50 dark:divide-gray-800">
|
<div className="flex border-b border-gray-100 dark:border-gray-800">
|
||||||
{notifications.length === 0 ? (
|
{tabs.map((tab) => (
|
||||||
<div className="px-4 py-8 text-center text-sm text-gray-400 dark:text-gray-500">
|
<button
|
||||||
No notifications yet
|
key={tab.key}
|
||||||
</div>
|
type="button"
|
||||||
) : (
|
onClick={() => setActiveTab(tab.key)}
|
||||||
notifications.map((n) => {
|
className={`flex-1 px-3 py-2 text-xs font-medium transition-colors ${
|
||||||
const isUnread = n.readAt === null;
|
activeTab === tab.key
|
||||||
return (
|
? "text-brand-600 dark:text-brand-400 border-b-2 border-brand-600 dark:border-brand-400"
|
||||||
<button
|
: "text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
|
||||||
key={n.id}
|
}`}
|
||||||
type="button"
|
>
|
||||||
onClick={() => {
|
{tab.label}
|
||||||
if (isUnread) handleMarkOne(n.id);
|
{tab.key === "tasks" && openTaskCount > 0 && (
|
||||||
}}
|
<span className="ml-1 inline-flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-bold text-white bg-orange-500 rounded-full">
|
||||||
className={`w-full text-left px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors ${
|
{openTaskCount}
|
||||||
isUnread ? "bg-blue-50/60 dark:bg-blue-900/10" : ""
|
</span>
|
||||||
}`}
|
)}
|
||||||
>
|
</button>
|
||||||
<div className="flex items-start gap-2">
|
))}
|
||||||
{isUnread && (
|
</div>
|
||||||
<span className="mt-1.5 w-2 h-2 rounded-full bg-blue-500 shrink-0" />
|
|
||||||
)}
|
{/* Content */}
|
||||||
<div className={isUnread ? "" : "ml-4"}>
|
<div className="max-h-80 overflow-y-auto divide-y divide-gray-50 dark:divide-gray-800">
|
||||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 leading-snug">
|
{activeTab === "all" && (
|
||||||
{n.title}
|
<>
|
||||||
</p>
|
{notifications.length === 0 ? (
|
||||||
{n.body && (
|
<div className="px-4 py-8 text-center text-sm text-gray-400 dark:text-gray-500">
|
||||||
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
|
No notifications yet
|
||||||
{n.body}
|
</div>
|
||||||
|
) : (
|
||||||
|
notifications.map((n) => {
|
||||||
|
const isUnread = n.readAt === null;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={n.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (isUnread) handleMarkOne(n.id);
|
||||||
|
}}
|
||||||
|
className={`w-full text-left px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors ${
|
||||||
|
isUnread ? "bg-blue-50/60 dark:bg-blue-900/10" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
{isUnread && (
|
||||||
|
<span className="mt-1.5 w-2 h-2 rounded-full bg-blue-500 shrink-0" />
|
||||||
|
)}
|
||||||
|
<div className={isUnread ? "" : "ml-4"}>
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 leading-snug">
|
||||||
|
{n.title}
|
||||||
|
</p>
|
||||||
|
{n.body && (
|
||||||
|
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
|
||||||
|
{n.body}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
{relativeTime(new Date(n.createdAt))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === "tasks" && (
|
||||||
|
<>
|
||||||
|
{tasks.length === 0 ? (
|
||||||
|
<div className="px-4 py-8 text-center text-sm text-gray-400 dark:text-gray-500">
|
||||||
|
No open tasks
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
tasks.map((t) => (
|
||||||
|
<div key={t.id} className="px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 leading-snug truncate">
|
||||||
|
{t.title}
|
||||||
</p>
|
</p>
|
||||||
)}
|
{t.dueDate && (
|
||||||
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
<p className={`mt-0.5 text-xs ${new Date(t.dueDate).getTime() < Date.now() ? "text-red-600 dark:text-red-400 font-semibold" : "text-gray-400 dark:text-gray-500"}`}>
|
||||||
{relativeTime(new Date(n.createdAt))}
|
Due: {new Date(t.dueDate).toLocaleDateString("en-GB")}
|
||||||
</p>
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleTaskAction(t.id, "DONE")}
|
||||||
|
disabled={updateTaskStatus.isPending}
|
||||||
|
className="p-1 text-green-600 hover:bg-green-100 dark:hover:bg-green-900/30 rounded transition-colors"
|
||||||
|
title="Mark as Done"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleTaskAction(t.id, "DISMISSED")}
|
||||||
|
disabled={updateTaskStatus.isPending}
|
||||||
|
className="p-1 text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded transition-colors"
|
||||||
|
title="Dismiss"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
))
|
||||||
);
|
)}
|
||||||
})
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === "reminders" && (
|
||||||
|
<>
|
||||||
|
{reminders.length === 0 ? (
|
||||||
|
<div className="px-4 py-8 text-center text-sm text-gray-400 dark:text-gray-500">
|
||||||
|
No reminders
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
reminders.map((r) => (
|
||||||
|
<div key={r.id} className="px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors">
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 leading-snug">
|
||||||
|
{r.title}
|
||||||
|
</p>
|
||||||
|
{r.body && (
|
||||||
|
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400 line-clamp-1">
|
||||||
|
{r.body}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
{r.nextRemindAt
|
||||||
|
? new Date(r.nextRemindAt).toLocaleDateString("en-GB", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})
|
||||||
|
: relativeTime(new Date(r.createdAt))}
|
||||||
|
{r.recurrence && (
|
||||||
|
<span className="ml-2 text-brand-500 dark:text-brand-400">{r.recurrence}</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="border-t border-gray-100 dark:border-gray-800 px-4 py-2">
|
||||||
|
<Link
|
||||||
|
href={"/notifications" as Route}
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="block text-center text-xs font-medium text-brand-600 dark:text-brand-400 hover:underline"
|
||||||
|
>
|
||||||
|
View all →
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,394 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
import { TaskCard } from "./TaskCard.js";
|
||||||
|
import { ReminderModal } from "./ReminderModal.js";
|
||||||
|
|
||||||
|
type TabKey = "all" | "notifications" | "tasks" | "reminders" | "approvals";
|
||||||
|
|
||||||
|
function relativeTime(date: Date): string {
|
||||||
|
const now = Date.now();
|
||||||
|
const diff = now - date.getTime();
|
||||||
|
const seconds = Math.floor(diff / 1000);
|
||||||
|
if (seconds < 60) return "just now";
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
if (days < 30) return `${days}d ago`;
|
||||||
|
const months = Math.floor(days / 30);
|
||||||
|
return `${months}mo ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationCenterClient() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const initialTab = (searchParams.get("tab") as TabKey) || "all";
|
||||||
|
const [activeTab, setActiveTab] = useState<TabKey>(initialTab);
|
||||||
|
const [reminderModal, setReminderModal] = useState<{
|
||||||
|
open: boolean;
|
||||||
|
reminder: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
body?: string | null;
|
||||||
|
remindAt?: string | Date | null;
|
||||||
|
recurrence?: string | null;
|
||||||
|
link?: string | null;
|
||||||
|
} | null;
|
||||||
|
}>({ open: false, reminder: null });
|
||||||
|
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
|
// Queries
|
||||||
|
const { data: allNotifications = [], isLoading: loadingAll } = trpc.notification.list.useQuery(
|
||||||
|
{ limit: 50 },
|
||||||
|
{ enabled: activeTab === "all", refetchInterval: 30_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: notifications = [], isLoading: loadingNotifications } = trpc.notification.list.useQuery(
|
||||||
|
{ category: "NOTIFICATION", limit: 50 },
|
||||||
|
{ enabled: activeTab === "notifications", refetchInterval: 30_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: tasks = [], isLoading: loadingTasks } = trpc.notification.listTasks.useQuery(
|
||||||
|
{ limit: 50 },
|
||||||
|
{ enabled: activeTab === "tasks", refetchInterval: 30_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: reminders = [], isLoading: loadingReminders } = trpc.notification.listReminders.useQuery(
|
||||||
|
{ limit: 50 },
|
||||||
|
{ enabled: activeTab === "reminders", refetchInterval: 30_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: approvals = [], isLoading: loadingApprovals } = trpc.notification.list.useQuery(
|
||||||
|
{ category: "APPROVAL", limit: 50 },
|
||||||
|
{ enabled: activeTab === "approvals", refetchInterval: 30_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: taskCounts } = trpc.notification.taskCounts.useQuery(undefined, {
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
const markRead = trpc.notification.markRead.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.notification.unreadCount.invalidate();
|
||||||
|
void utils.notification.list.invalidate();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateTaskStatus = trpc.notification.updateTaskStatus.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.notification.taskCounts.invalidate();
|
||||||
|
void utils.notification.listTasks.invalidate();
|
||||||
|
void utils.notification.list.invalidate();
|
||||||
|
void utils.notification.unreadCount.invalidate();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteReminder = trpc.notification.deleteReminder.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.notification.listReminders.invalidate();
|
||||||
|
void utils.notification.list.invalidate();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleTaskStatusChange(id: string, status: string) {
|
||||||
|
updateTaskStatus.mutate({ id, status: status as "OPEN" | "IN_PROGRESS" | "DONE" | "DISMISSED" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMarkAllRead() {
|
||||||
|
markRead.mutate({});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs: { key: TabKey; label: string; count?: number }[] = [
|
||||||
|
{ key: "all", label: "All" },
|
||||||
|
{ key: "notifications", label: "Notifications" },
|
||||||
|
{ key: "tasks", label: "Tasks", count: (taskCounts?.open ?? 0) + (taskCounts?.inProgress ?? 0) },
|
||||||
|
{ key: "reminders", label: "Reminders" },
|
||||||
|
{ key: "approvals", label: "Approvals" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const isLoading =
|
||||||
|
(activeTab === "all" && loadingAll) ||
|
||||||
|
(activeTab === "notifications" && loadingNotifications) ||
|
||||||
|
(activeTab === "tasks" && loadingTasks) ||
|
||||||
|
(activeTab === "reminders" && loadingReminders) ||
|
||||||
|
(activeTab === "approvals" && loadingApprovals);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl px-4 py-8 sm:px-6">
|
||||||
|
{/* Page header */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Notification Center</h1>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{activeTab === "reminders" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setReminderModal({ open: true, reminder: null })}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-lg bg-brand-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-700 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
New Reminder
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleMarkAllRead}
|
||||||
|
disabled={markRead.isPending}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 dark:border-gray-600 px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Mark all read
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-1 border-b border-gray-200 dark:border-gray-700 mb-6">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab(tab.key)}
|
||||||
|
className={`px-4 py-2.5 text-sm font-medium transition-colors ${
|
||||||
|
activeTab === tab.key
|
||||||
|
? "text-brand-600 dark:text-brand-400 border-b-2 border-brand-600 dark:border-brand-400"
|
||||||
|
: "text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
{tab.count !== undefined && tab.count > 0 && (
|
||||||
|
<span className="ml-1.5 inline-flex items-center justify-center min-w-[18px] h-5 px-1.5 text-[10px] font-bold text-white bg-orange-500 rounded-full">
|
||||||
|
{tab.count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading state */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<div key={i} className="animate-pulse rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<div className="h-4 w-3/4 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||||
|
<div className="mt-2 h-3 w-1/2 bg-gray-100 dark:bg-gray-800 rounded" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{!isLoading && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* All / Notifications / Approvals tabs */}
|
||||||
|
{(activeTab === "all" || activeTab === "notifications" || activeTab === "approvals") && (
|
||||||
|
<>
|
||||||
|
{(activeTab === "all" ? allNotifications : activeTab === "notifications" ? notifications : approvals).length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-dashed border-gray-300 dark:border-gray-600 py-12 text-center text-sm text-gray-400 dark:text-gray-500">
|
||||||
|
{activeTab === "approvals" ? "No pending approvals" : "No notifications"}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
(activeTab === "all" ? allNotifications : activeTab === "notifications" ? notifications : approvals).map((n) => {
|
||||||
|
const isUnread = n.readAt === null;
|
||||||
|
const isTask = n.category === "TASK" || n.category === "APPROVAL";
|
||||||
|
|
||||||
|
if (isTask && n.taskStatus) {
|
||||||
|
return (
|
||||||
|
<TaskCard
|
||||||
|
key={n.id}
|
||||||
|
task={{
|
||||||
|
id: n.id,
|
||||||
|
title: n.title,
|
||||||
|
body: n.body,
|
||||||
|
priority: n.priority ?? "NORMAL",
|
||||||
|
taskStatus: n.taskStatus,
|
||||||
|
taskAction: n.taskAction,
|
||||||
|
dueDate: n.dueDate,
|
||||||
|
entityType: n.entityType,
|
||||||
|
link: n.link,
|
||||||
|
createdAt: n.createdAt,
|
||||||
|
completedBy: n.completedBy,
|
||||||
|
}}
|
||||||
|
onStatusChange={handleTaskStatusChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={n.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (isUnread) markRead.mutate({ id: n.id });
|
||||||
|
}}
|
||||||
|
className={`w-full text-left rounded-xl border border-gray-200 dark:border-gray-700 px-4 py-3 transition-colors hover:bg-gray-50 dark:hover:bg-gray-800 ${
|
||||||
|
isUnread ? "bg-blue-50/60 dark:bg-blue-900/10" : "bg-white dark:bg-gray-900/70"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
{isUnread && (
|
||||||
|
<span className="mt-1.5 w-2 h-2 rounded-full bg-blue-500 shrink-0" />
|
||||||
|
)}
|
||||||
|
<div className={isUnread ? "flex-1" : "flex-1 ml-4"}>
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 leading-snug">
|
||||||
|
{n.title}
|
||||||
|
</p>
|
||||||
|
{n.body && (
|
||||||
|
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
|
||||||
|
{n.body}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-1 flex items-center gap-3 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
<span>{relativeTime(new Date(n.createdAt))}</span>
|
||||||
|
{n.category && n.category !== "NOTIFICATION" && (
|
||||||
|
<span className="rounded-full bg-gray-100 dark:bg-gray-800 px-2 py-0.5 text-[10px] font-medium uppercase">
|
||||||
|
{n.category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tasks tab */}
|
||||||
|
{activeTab === "tasks" && (
|
||||||
|
<>
|
||||||
|
{tasks.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-dashed border-gray-300 dark:border-gray-600 py-12 text-center text-sm text-gray-400 dark:text-gray-500">
|
||||||
|
No tasks
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
tasks.map((t) => (
|
||||||
|
<TaskCard
|
||||||
|
key={t.id}
|
||||||
|
task={{
|
||||||
|
id: t.id,
|
||||||
|
title: t.title,
|
||||||
|
body: t.body,
|
||||||
|
priority: t.priority ?? "NORMAL",
|
||||||
|
taskStatus: t.taskStatus,
|
||||||
|
taskAction: t.taskAction,
|
||||||
|
dueDate: t.dueDate,
|
||||||
|
entityType: t.entityType,
|
||||||
|
link: t.link,
|
||||||
|
createdAt: t.createdAt,
|
||||||
|
completedBy: t.completedBy,
|
||||||
|
}}
|
||||||
|
onStatusChange={handleTaskStatusChange}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reminders tab */}
|
||||||
|
{activeTab === "reminders" && (
|
||||||
|
<>
|
||||||
|
{reminders.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-dashed border-gray-300 dark:border-gray-600 py-12 text-center text-sm text-gray-400 dark:text-gray-500">
|
||||||
|
No reminders. Create one to get started.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
reminders.map((r) => (
|
||||||
|
<div
|
||||||
|
key={r.id}
|
||||||
|
className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900/70 px-4 py-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 leading-snug">
|
||||||
|
{r.title}
|
||||||
|
</p>
|
||||||
|
{r.body && (
|
||||||
|
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
|
||||||
|
{r.body}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-1 flex items-center gap-3 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
{r.nextRemindAt && (
|
||||||
|
<span>
|
||||||
|
{new Date(r.nextRemindAt).toLocaleDateString("en-GB", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{r.recurrence && (
|
||||||
|
<span className="rounded-full bg-brand-100 dark:bg-brand-900/30 px-2 py-0.5 text-[10px] font-medium text-brand-700 dark:text-brand-300 uppercase">
|
||||||
|
{r.recurrence}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setReminderModal({
|
||||||
|
open: true,
|
||||||
|
reminder: {
|
||||||
|
id: r.id,
|
||||||
|
title: r.title,
|
||||||
|
body: r.body,
|
||||||
|
remindAt: r.nextRemindAt ?? r.remindAt,
|
||||||
|
recurrence: r.recurrence,
|
||||||
|
link: r.link,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 rounded transition-colors"
|
||||||
|
title="Edit"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm("Delete this reminder?")) {
|
||||||
|
deleteReminder.mutate({ id: r.id });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={deleteReminder.isPending}
|
||||||
|
className="p-1 text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-gray-100 dark:hover:bg-gray-800 rounded transition-colors"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reminder Modal */}
|
||||||
|
{reminderModal.open && (
|
||||||
|
<ReminderModal
|
||||||
|
reminder={reminderModal.reminder}
|
||||||
|
onClose={() => setReminderModal({ open: false, reminder: null })}
|
||||||
|
onSuccess={() => setReminderModal({ open: false, reminder: null })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
|
||||||
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
import { DateInput } from "~/components/ui/DateInput.js";
|
||||||
|
|
||||||
|
const RECURRENCE_OPTIONS = [
|
||||||
|
{ value: "", label: "None" },
|
||||||
|
{ value: "daily", label: "Daily" },
|
||||||
|
{ value: "weekly", label: "Weekly" },
|
||||||
|
{ value: "monthly", label: "Monthly" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
interface ReminderModalProps {
|
||||||
|
reminder?: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
body?: string | null;
|
||||||
|
remindAt?: string | Date | null;
|
||||||
|
recurrence?: string | null;
|
||||||
|
link?: string | null;
|
||||||
|
} | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDateInputValue(date: Date | string | null | undefined): string {
|
||||||
|
if (!date) return "";
|
||||||
|
const d = typeof date === "string" ? new Date(date) : date;
|
||||||
|
return d.toISOString().split("T")[0] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTimeInputValue(date: Date | string | null | undefined): string {
|
||||||
|
if (!date) return "09:00";
|
||||||
|
const d = typeof date === "string" ? new Date(date) : date;
|
||||||
|
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReminderModal({ reminder, onClose, onSuccess }: ReminderModalProps) {
|
||||||
|
const isEdit = !!reminder;
|
||||||
|
|
||||||
|
const [title, setTitle] = useState(reminder?.title ?? "");
|
||||||
|
const [body, setBody] = useState(reminder?.body ?? "");
|
||||||
|
const [remindDate, setRemindDate] = useState(toDateInputValue(reminder?.remindAt));
|
||||||
|
const [remindTime, setRemindTime] = useState(toTimeInputValue(reminder?.remindAt));
|
||||||
|
const [recurrence, setRecurrence] = useState(reminder?.recurrence ?? "");
|
||||||
|
const [link, setLink] = useState(reminder?.link ?? "");
|
||||||
|
const [serverError, setServerError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
useFocusTrap(panelRef, true);
|
||||||
|
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
|
const createMutation = trpc.notification.createReminder.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
await utils.notification.listReminders.invalidate();
|
||||||
|
await utils.notification.list.invalidate();
|
||||||
|
onSuccess();
|
||||||
|
},
|
||||||
|
onError: (err) => setServerError(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = trpc.notification.updateReminder.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
await utils.notification.listReminders.invalidate();
|
||||||
|
await utils.notification.list.invalidate();
|
||||||
|
onSuccess();
|
||||||
|
},
|
||||||
|
onError: (err) => setServerError(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = trpc.notification.deleteReminder.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
await utils.notification.listReminders.invalidate();
|
||||||
|
await utils.notification.list.invalidate();
|
||||||
|
onSuccess();
|
||||||
|
},
|
||||||
|
onError: (err) => setServerError(err.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const isPending = createMutation.isPending || updateMutation.isPending || deleteMutation.isPending;
|
||||||
|
|
||||||
|
function buildRemindAt(): Date | null {
|
||||||
|
if (!remindDate) return null;
|
||||||
|
const [hours, minutes] = remindTime.split(":").map(Number);
|
||||||
|
const d = new Date(remindDate + "T00:00:00");
|
||||||
|
d.setHours(hours ?? 9, minutes ?? 0, 0, 0);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setServerError(null);
|
||||||
|
|
||||||
|
const remindAt = buildRemindAt();
|
||||||
|
if (!title.trim()) {
|
||||||
|
setServerError("Title is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!remindAt) {
|
||||||
|
setServerError("Remind date is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEdit && reminder) {
|
||||||
|
updateMutation.mutate({
|
||||||
|
id: reminder.id,
|
||||||
|
title: title.trim(),
|
||||||
|
body: body.trim() || undefined,
|
||||||
|
remindAt,
|
||||||
|
recurrence: (recurrence || null) as "daily" | "weekly" | "monthly" | null,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
createMutation.mutate({
|
||||||
|
title: title.trim(),
|
||||||
|
body: body.trim() || undefined,
|
||||||
|
remindAt,
|
||||||
|
...(recurrence ? { recurrence: recurrence as "daily" | "weekly" | "monthly" } : {}),
|
||||||
|
...(link.trim() ? { link: link.trim() } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete() {
|
||||||
|
if (!reminder) return;
|
||||||
|
if (!window.confirm("Delete this reminder?")) return;
|
||||||
|
deleteMutation.mutate({ id: reminder.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100";
|
||||||
|
const labelClass = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-lg mx-4"
|
||||||
|
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||||
|
{isEdit ? "Edit Reminder" : "New Reminder"}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors text-xl leading-none"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="px-6 py-5 space-y-4">
|
||||||
|
{/* Title */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="rem-title" className={labelClass}>
|
||||||
|
Title <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="rem-title"
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
maxLength={200}
|
||||||
|
className={inputClass}
|
||||||
|
required
|
||||||
|
placeholder="Reminder title..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="rem-body" className={labelClass}>
|
||||||
|
Description (optional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="rem-body"
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
maxLength={2000}
|
||||||
|
className={`${inputClass} resize-none`}
|
||||||
|
placeholder="Additional details..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date + Time */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="rem-date" className={labelClass}>
|
||||||
|
Remind Date <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<DateInput
|
||||||
|
id="rem-date"
|
||||||
|
value={remindDate}
|
||||||
|
onChange={setRemindDate}
|
||||||
|
className={inputClass}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="rem-time" className={labelClass}>
|
||||||
|
Remind Time <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="rem-time"
|
||||||
|
type="time"
|
||||||
|
value={remindTime}
|
||||||
|
onChange={(e) => setRemindTime(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recurrence */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="rem-recurrence" className={labelClass}>
|
||||||
|
Recurrence
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="rem-recurrence"
|
||||||
|
value={recurrence}
|
||||||
|
onChange={(e) => setRecurrence(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
{RECURRENCE_OPTIONS.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Link */}
|
||||||
|
{!isEdit && (
|
||||||
|
<div>
|
||||||
|
<label htmlFor="rem-link" className={labelClass}>
|
||||||
|
Link (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="rem-link"
|
||||||
|
type="text"
|
||||||
|
value={link}
|
||||||
|
onChange={(e) => setLink(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="/projects/abc or https://..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Server error */}
|
||||||
|
{serverError && (
|
||||||
|
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700 dark:bg-red-900/20 dark:border-red-800 dark:text-red-300">
|
||||||
|
{serverError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-between pt-2">
|
||||||
|
<div>
|
||||||
|
{isEdit && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isPending}
|
||||||
|
className="text-sm text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isPending}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isPending ? "Saving..." : isEdit ? "Update" : "Create Reminder"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { Route } from "next";
|
||||||
|
|
||||||
|
const PRIORITY_STYLES: Record<string, { bg: string; text: string; label: string }> = {
|
||||||
|
URGENT: { bg: "bg-red-100 dark:bg-red-900/30", text: "text-red-700 dark:text-red-300", label: "Urgent" },
|
||||||
|
HIGH: { bg: "bg-orange-100 dark:bg-orange-900/30", text: "text-orange-700 dark:text-orange-300", label: "High" },
|
||||||
|
NORMAL: { bg: "bg-blue-100 dark:bg-blue-900/30", text: "text-blue-700 dark:text-blue-300", label: "Normal" },
|
||||||
|
LOW: { bg: "bg-gray-100 dark:bg-gray-800", text: "text-gray-500 dark:text-gray-400", label: "Low" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
OPEN: "Open",
|
||||||
|
IN_PROGRESS: "In Progress",
|
||||||
|
DONE: "Done",
|
||||||
|
DISMISSED: "Dismissed",
|
||||||
|
};
|
||||||
|
|
||||||
|
function isOverdue(dueDate: string | Date | null | undefined): boolean {
|
||||||
|
if (!dueDate) return false;
|
||||||
|
const d = typeof dueDate === "string" ? new Date(dueDate) : dueDate;
|
||||||
|
return d.getTime() < Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date: string | Date): string {
|
||||||
|
const d = typeof date === "string" ? new Date(date) : date;
|
||||||
|
return d.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function isApprovalAction(action: string | null | undefined): boolean {
|
||||||
|
return !!action && action.startsWith("approve_");
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskCardProps {
|
||||||
|
task: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
body?: string | null;
|
||||||
|
priority: string;
|
||||||
|
taskStatus?: string | null;
|
||||||
|
taskAction?: string | null;
|
||||||
|
dueDate?: string | Date | null;
|
||||||
|
entityType?: string | null;
|
||||||
|
link?: string | null;
|
||||||
|
createdAt: string | Date;
|
||||||
|
completedBy?: string | null;
|
||||||
|
};
|
||||||
|
onStatusChange?: (id: string, status: string) => void;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskCard({ task, onStatusChange, compact }: TaskCardProps) {
|
||||||
|
const priority = PRIORITY_STYLES[task.priority] ?? PRIORITY_STYLES.NORMAL!;
|
||||||
|
const status = task.taskStatus ?? "OPEN";
|
||||||
|
const overdue = isOverdue(task.dueDate) && status !== "DONE" && status !== "DISMISSED";
|
||||||
|
const isDone = status === "DONE" || status === "DISMISSED";
|
||||||
|
const showApprovalButtons = isApprovalAction(task.taskAction) && !isDone;
|
||||||
|
|
||||||
|
const titleContent = (
|
||||||
|
<span className={`text-sm font-medium leading-snug ${isDone ? "line-through text-gray-400 dark:text-gray-500" : "text-gray-900 dark:text-gray-100"}`}>
|
||||||
|
{task.title}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`rounded-xl border border-gray-200 dark:border-gray-700 ${isDone ? "bg-gray-50 dark:bg-gray-900/40" : "bg-white dark:bg-gray-900/70"} ${compact ? "p-3" : "p-4"} transition-colors`}>
|
||||||
|
{/* Header row */}
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{task.link && !isDone ? (
|
||||||
|
<Link href={task.link as Route} className="hover:underline">
|
||||||
|
{titleContent}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
titleContent
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!compact && task.body && (
|
||||||
|
<p className="mt-0.5 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">
|
||||||
|
{task.body}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Priority badge */}
|
||||||
|
<span className={`shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${priority.bg} ${priority.text}`}>
|
||||||
|
{priority.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Meta row */}
|
||||||
|
<div className={`flex items-center gap-3 ${compact ? "mt-1.5" : "mt-2"} text-xs`}>
|
||||||
|
{/* Status */}
|
||||||
|
<span className="text-gray-400 dark:text-gray-500">
|
||||||
|
{STATUS_LABELS[status] ?? status}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Due date */}
|
||||||
|
{task.dueDate && (
|
||||||
|
<span className={overdue ? "font-semibold text-red-600 dark:text-red-400" : "text-gray-400 dark:text-gray-500"}>
|
||||||
|
{overdue ? "Overdue: " : "Due: "}
|
||||||
|
{formatDate(task.dueDate)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Entity type */}
|
||||||
|
{!compact && task.entityType && (
|
||||||
|
<span className="text-gray-400 dark:text-gray-500">
|
||||||
|
{task.entityType}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Completed by */}
|
||||||
|
{isDone && task.completedBy && (
|
||||||
|
<span className="text-green-600 dark:text-green-400">
|
||||||
|
Completed
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
{!isDone && onStatusChange && (
|
||||||
|
<div className={`flex items-center gap-2 ${compact ? "mt-2" : "mt-3"}`}>
|
||||||
|
{showApprovalButtons ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onStatusChange(task.id, "DONE")}
|
||||||
|
className="inline-flex items-center gap-1 rounded-lg bg-green-600 px-2.5 py-1 text-xs font-medium text-white hover:bg-green-700 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
Approve
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onStatusChange(task.id, "DISMISSED")}
|
||||||
|
className="inline-flex items-center gap-1 rounded-lg bg-red-600 px-2.5 py-1 text-xs font-medium text-white hover:bg-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{status === "OPEN" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onStatusChange(task.id, "IN_PROGRESS")}
|
||||||
|
className="inline-flex items-center gap-1 rounded-lg border border-gray-300 dark:border-gray-600 px-2.5 py-1 text-xs font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
Start
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onStatusChange(task.id, "DONE")}
|
||||||
|
className="inline-flex items-center gap-1 rounded-lg bg-green-600 px-2.5 py-1 text-xs font-medium text-white hover:bg-green-700 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onStatusChange(task.id, "DISMISSED")}
|
||||||
|
className="inline-flex items-center gap-1 rounded-lg border border-gray-300 dark:border-gray-600 px-2.5 py-1 text-xs font-medium text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -40,6 +40,21 @@ export function useTimelineSSE() {
|
|||||||
void queryClient.invalidateQueries({ queryKey: [["timeline", "getBudgetStatus"]] });
|
void queryClient.invalidateQueries({ queryKey: [["timeline", "getBudgetStatus"]] });
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case SSE_EVENT_TYPES.TASK_ASSIGNED:
|
||||||
|
case SSE_EVENT_TYPES.TASK_COMPLETED:
|
||||||
|
case SSE_EVENT_TYPES.TASK_STATUS_CHANGED:
|
||||||
|
void queryClient.invalidateQueries({ queryKey: [["notification", "listTasks"]] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: [["notification", "taskCounts"]] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: [["notification", "unreadCount"]] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: [["notification", "list"]] });
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SSE_EVENT_TYPES.REMINDER_DUE:
|
||||||
|
void queryClient.invalidateQueries({ queryKey: [["notification", "list"]] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: [["notification", "unreadCount"]] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: [["notification", "listReminders"]] });
|
||||||
|
break;
|
||||||
|
|
||||||
case SSE_EVENT_TYPES.PING:
|
case SSE_EVENT_TYPES.PING:
|
||||||
reconnectAttempts = 0; // Reset on successful ping
|
reconnectAttempts = 0; // Reset on successful ping
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
".": "./src/index.ts",
|
".": "./src/index.ts",
|
||||||
"./router": "./src/router/index.ts",
|
"./router": "./src/router/index.ts",
|
||||||
"./trpc": "./src/trpc.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": {
|
"scripts": {
|
||||||
"typecheck": "tsc --noEmit",
|
"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";
|
} from "@planarchy/application";
|
||||||
import {
|
import {
|
||||||
AllocationStatus,
|
AllocationStatus,
|
||||||
|
buildTaskAction,
|
||||||
CreateAllocationSchema,
|
CreateAllocationSchema,
|
||||||
CreateAssignmentSchema,
|
CreateAssignmentSchema,
|
||||||
CreateDemandRequirementSchema,
|
CreateDemandRequirementSchema,
|
||||||
@@ -28,7 +29,7 @@ import { TRPCError } from "@trpc/server";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { findUniqueOrThrow } from "../db/helpers.js";
|
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||||
import { anonymizeResource, getAnonymizationDirectory } from "../lib/anonymization.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 { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
|
||||||
import { PROJECT_BRIEF_SELECT, RESOURCE_BRIEF_SELECT, ROLE_BRIEF_SELECT } from "../db/selects.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,
|
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;
|
return demandRequirement;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,23 @@
|
|||||||
import { prisma } from "@planarchy/db";
|
import { prisma } from "@planarchy/db";
|
||||||
import { computeBudgetStatus } from "@planarchy/engine";
|
import { computeBudgetStatus } from "@planarchy/engine";
|
||||||
import type { PermissionKey } from "@planarchy/shared";
|
import type { PermissionKey } from "@planarchy/shared";
|
||||||
|
import { parseTaskAction } from "@planarchy/shared";
|
||||||
import { createDalleClient, isDalleConfigured, parseAiError } from "../ai-client.js";
|
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 ──────────────────────────────────────────────────────────────────
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type ToolContext = {
|
export type ToolContext = {
|
||||||
db: typeof prisma;
|
db: typeof prisma;
|
||||||
|
userId: string;
|
||||||
userRole: string;
|
userRole: string;
|
||||||
permissions: Set<PermissionKey>;
|
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 ────────────────────────────────────────────────────────────────
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
@@ -3496,6 +3626,392 @@ const executors = {
|
|||||||
|
|
||||||
return { __action: "invalidate", scope: ["project"], success: true, message: `Removed cover art from project "${project.name}"` };
|
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 ───────────────────────────────────────────────────────────────
|
// ─── Executor ───────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ Deine Fähigkeiten:
|
|||||||
- Rollen, Clients, Org-Units erstellen/aktualisieren/löschen
|
- Rollen, Clients, Org-Units erstellen/aktualisieren/löschen
|
||||||
- Estimates erstellen, Rate Cards abrufen, Blueprints anzeigen
|
- Estimates erstellen, Rate Cards abrufen, Blueprints anzeigen
|
||||||
- Notifications anzeigen, Dashboard-Details abrufen
|
- 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)
|
- Den User zu relevanten Seiten navigieren (Timeline, Dashboard, etc. mit Filtern)
|
||||||
- Verfügbarkeit von Ressourcen prüfen, Kapazitäten suchen
|
- Verfügbarkeit von Ressourcen prüfen, Kapazitäten suchen
|
||||||
|
|
||||||
@@ -75,6 +78,10 @@ const TOOL_PERMISSION_MAP: Record<string, string> = {
|
|||||||
reject_vacation: "manageVacations",
|
reject_vacation: "manageVacations",
|
||||||
cancel_vacation: "manageVacations",
|
cancel_vacation: "manageVacations",
|
||||||
set_entitlement: "manageVacations",
|
set_entitlement: "manageVacations",
|
||||||
|
// Task management
|
||||||
|
create_task_for_user: "manageProjects",
|
||||||
|
send_broadcast: "manageProjects",
|
||||||
|
execute_task_action: "manageAllocations",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Tools that require cost visibility */
|
/** Tools that require cost visibility */
|
||||||
@@ -153,7 +160,7 @@ export const assistantRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 5. Function calling loop
|
// 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[] = [];
|
const collectedActions: ToolAction[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
|
for (let i = 0; i < MAX_TOOL_ITERATIONS; i++) {
|
||||||
|
|||||||
@@ -1,26 +1,88 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { createTRPCRouter, managerProcedure, protectedProcedure } from "../trpc.js";
|
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. */
|
/** Resolve the DB user id from the session email. Throws UNAUTHORIZED if not found. */
|
||||||
async function resolveUserId(ctx: {
|
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 };
|
session: { user?: { email?: string | null } | null };
|
||||||
}): Promise<string> {
|
}): Promise<string> {
|
||||||
const email = ctx.session.user?.email;
|
const email = ctx.session.user?.email;
|
||||||
if (!email) throw new TRPCError({ code: "UNAUTHORIZED" });
|
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" });
|
if (!user) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
return user.id;
|
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({
|
export const notificationRouter = createTRPCRouter({
|
||||||
/** List notifications for the current user */
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// EXISTING (enhanced)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/** List notifications for the current user with optional filters */
|
||||||
list: protectedProcedure
|
list: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
unreadOnly: z.boolean().optional(),
|
unreadOnly: z.boolean().optional(),
|
||||||
|
category: categoryEnum.optional(),
|
||||||
|
taskStatus: taskStatusEnum.optional(),
|
||||||
|
priority: priorityEnum.optional(),
|
||||||
limit: z.number().min(1).max(100).default(50),
|
limit: z.number().min(1).max(100).default(50),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -30,6 +92,9 @@ export const notificationRouter = createTRPCRouter({
|
|||||||
where: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
...(input.unreadOnly ? { readAt: null } : {}),
|
...(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" },
|
orderBy: { createdAt: "desc" },
|
||||||
take: input.limit,
|
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
|
create: managerProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -73,9 +138,21 @@ export const notificationRouter = createTRPCRouter({
|
|||||||
body: z.string().optional(),
|
body: z.string().optional(),
|
||||||
entityId: z.string().optional(),
|
entityId: z.string().optional(),
|
||||||
entityType: 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 }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const currentUserId = ctx.dbUser.id;
|
||||||
|
|
||||||
const n = await ctx.db.notification.create({
|
const n = await ctx.db.notification.create({
|
||||||
data: {
|
data: {
|
||||||
userId: input.userId,
|
userId: input.userId,
|
||||||
@@ -84,9 +161,489 @@ export const notificationRouter = createTRPCRouter({
|
|||||||
...(input.body !== undefined ? { body: input.body } : {}),
|
...(input.body !== undefined ? { body: input.body } : {}),
|
||||||
...(input.entityId !== undefined ? { entityId: input.entityId } : {}),
|
...(input.entityId !== undefined ? { entityId: input.entityId } : {}),
|
||||||
...(input.entityType !== undefined ? { entityType: input.entityType } : {}),
|
...(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);
|
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;
|
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 { VacationStatus, VacationType } from "@planarchy/db";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { findUniqueOrThrow } from "../db/helpers.js";
|
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 { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js";
|
||||||
import { sendEmail } from "../lib/email.js";
|
import { sendEmail } from "../lib/email.js";
|
||||||
import { anonymizeResource, anonymizeUser, getAnonymizationDirectory } from "../lib/anonymization.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 });
|
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);
|
const directory = await getAnonymizationDirectory(ctx.db);
|
||||||
return anonymizeVacationRecord(vacation, directory);
|
return anonymizeVacationRecord(vacation, directory);
|
||||||
}),
|
}),
|
||||||
@@ -253,6 +288,20 @@ export const vacationRouter = createTRPCRouter({
|
|||||||
|
|
||||||
emitVacationUpdated({ id: updated.id, resourceId: updated.resourceId, status: updated.status });
|
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) {
|
if (existing.status === VacationStatus.PENDING) {
|
||||||
void notifyVacationStatus(ctx.db, updated.id, updated.resourceId, VacationStatus.APPROVED);
|
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 });
|
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);
|
void notifyVacationStatus(ctx.db, updated.id, updated.resourceId, VacationStatus.REJECTED, input.rejectionReason);
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
@@ -317,6 +385,20 @@ export const vacationRouter = createTRPCRouter({
|
|||||||
for (const v of vacations) {
|
for (const v of vacations) {
|
||||||
emitVacationUpdated({ id: v.id, resourceId: v.resourceId, status: VacationStatus.APPROVED });
|
emitVacationUpdated({ id: v.id, resourceId: v.resourceId, status: VacationStatus.APPROVED });
|
||||||
void notifyVacationStatus(ctx.db, v.id, v.resourceId, 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 };
|
return { approved: vacations.length };
|
||||||
@@ -333,6 +415,11 @@ export const vacationRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.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({
|
const vacations = await ctx.db.vacation.findMany({
|
||||||
where: { id: { in: input.ids }, status: VacationStatus.PENDING },
|
where: { id: { in: input.ids }, status: VacationStatus.PENDING },
|
||||||
select: { id: true, resourceId: true },
|
select: { id: true, resourceId: true },
|
||||||
@@ -349,6 +436,20 @@ export const vacationRouter = createTRPCRouter({
|
|||||||
for (const v of vacations) {
|
for (const v of vacations) {
|
||||||
emitVacationUpdated({ id: v.id, resourceId: v.resourceId, status: VacationStatus.REJECTED });
|
emitVacationUpdated({ id: v.id, resourceId: v.resourceId, status: VacationStatus.REJECTED });
|
||||||
void notifyVacationStatus(ctx.db, v.id, v.resourceId, VacationStatus.REJECTED, input.rejectionReason);
|
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 };
|
return { rejected: vacations.length };
|
||||||
|
|||||||
@@ -209,3 +209,23 @@ export const emitRoleDeleted = (roleId: string) =>
|
|||||||
export function emitNotificationCreated(userId: string, notificationId: string): void {
|
export function emitNotificationCreated(userId: string, notificationId: string): void {
|
||||||
eventBus.emit(SSE_EVENT_TYPES.NOTIFICATION_CREATED, { userId, notificationId });
|
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 });
|
||||||
|
}
|
||||||
|
|||||||
@@ -185,6 +185,9 @@ model User {
|
|||||||
vacationsApproved Vacation[] @relation("vacation_approved")
|
vacationsApproved Vacation[] @relation("vacation_approved")
|
||||||
resource Resource?
|
resource Resource?
|
||||||
notifications Notification[]
|
notifications Notification[]
|
||||||
|
tasksAssigned Notification[] @relation("taskAssignee")
|
||||||
|
notificationsSent Notification[] @relation("notificationSender")
|
||||||
|
broadcasts NotificationBroadcast[] @relation("broadcastSender")
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -1286,6 +1289,29 @@ model VacationEntitlement {
|
|||||||
@@map("vacation_entitlements")
|
@@map("vacation_entitlements")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Notification Enums ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
enum NotificationCategory {
|
||||||
|
NOTIFICATION
|
||||||
|
REMINDER
|
||||||
|
TASK
|
||||||
|
APPROVAL
|
||||||
|
}
|
||||||
|
|
||||||
|
enum NotificationPriority {
|
||||||
|
LOW
|
||||||
|
NORMAL
|
||||||
|
HIGH
|
||||||
|
URGENT
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TaskStatus {
|
||||||
|
OPEN
|
||||||
|
IN_PROGRESS
|
||||||
|
DONE
|
||||||
|
DISMISSED
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Notification ─────────────────────────────────────────────────────────────
|
// ─── Notification ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
model Notification {
|
model Notification {
|
||||||
@@ -1299,12 +1325,71 @@ model Notification {
|
|||||||
readAt DateTime?
|
readAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
// Extended fields
|
||||||
|
category NotificationCategory @default(NOTIFICATION)
|
||||||
|
priority NotificationPriority @default(NORMAL)
|
||||||
|
link String?
|
||||||
|
|
||||||
|
// Task fields
|
||||||
|
taskStatus TaskStatus?
|
||||||
|
taskAction String?
|
||||||
|
assigneeId String?
|
||||||
|
dueDate DateTime?
|
||||||
|
completedAt DateTime?
|
||||||
|
completedBy String?
|
||||||
|
|
||||||
|
// Reminder fields
|
||||||
|
remindAt DateTime?
|
||||||
|
recurrence String? // "daily" | "weekly" | "monthly" | null
|
||||||
|
nextRemindAt DateTime?
|
||||||
|
|
||||||
|
// Targeting
|
||||||
|
sourceId String?
|
||||||
|
senderId String?
|
||||||
|
channel String @default("in_app")
|
||||||
|
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
assignee User? @relation("taskAssignee", fields: [assigneeId], references: [id])
|
||||||
|
sender User? @relation("notificationSender", fields: [senderId], references: [id])
|
||||||
|
|
||||||
@@index([userId, readAt])
|
@@index([userId, readAt])
|
||||||
|
@@index([userId, category, taskStatus])
|
||||||
|
@@index([nextRemindAt])
|
||||||
|
@@index([assigneeId, taskStatus])
|
||||||
@@map("notifications")
|
@@map("notifications")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Notification Broadcast ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
model NotificationBroadcast {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
senderId String
|
||||||
|
title String
|
||||||
|
body String?
|
||||||
|
link String?
|
||||||
|
category NotificationCategory @default(NOTIFICATION)
|
||||||
|
priority NotificationPriority @default(NORMAL)
|
||||||
|
channel String @default("in_app")
|
||||||
|
|
||||||
|
targetType String // "user" | "role" | "project" | "orgUnit" | "all"
|
||||||
|
targetValue String?
|
||||||
|
|
||||||
|
scheduledAt DateTime?
|
||||||
|
sentAt DateTime?
|
||||||
|
recipientCount Int @default(0)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
sender User @relation("broadcastSender", fields: [senderId], references: [id])
|
||||||
|
|
||||||
|
@@index([senderId])
|
||||||
|
@@index([scheduledAt, sentAt])
|
||||||
|
@@map("notification_broadcasts")
|
||||||
|
}
|
||||||
|
|
||||||
// ─── System Settings ──────────────────────────────────────────────────────────
|
// ─── System Settings ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
model SystemSettings {
|
model SystemSettings {
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ export const SSE_EVENT_TYPES = {
|
|||||||
ROLE_UPDATED: "role.updated",
|
ROLE_UPDATED: "role.updated",
|
||||||
ROLE_DELETED: "role.deleted",
|
ROLE_DELETED: "role.deleted",
|
||||||
NOTIFICATION_CREATED: "notification:created",
|
NOTIFICATION_CREATED: "notification:created",
|
||||||
|
TASK_ASSIGNED: "task.assigned",
|
||||||
|
TASK_COMPLETED: "task.completed",
|
||||||
|
TASK_STATUS_CHANGED: "task.status_changed",
|
||||||
|
REMINDER_DUE: "reminder.due",
|
||||||
|
BROADCAST_SENT: "broadcast.sent",
|
||||||
PING: "ping",
|
PING: "ping",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export * from "./engine.js";
|
|||||||
export * from "./staffing.js";
|
export * from "./staffing.js";
|
||||||
export * from "./vacation.js";
|
export * from "./vacation.js";
|
||||||
export * from "./role.js";
|
export * from "./role.js";
|
||||||
export type { Notification } from "./notification.js";
|
export * from "./notification.js";
|
||||||
export * from "./permissions.js";
|
export * from "./permissions.js";
|
||||||
export * from "./columns.js";
|
export * from "./columns.js";
|
||||||
export * from "./dashboard.js";
|
export * from "./dashboard.js";
|
||||||
|
|||||||
@@ -1,3 +1,37 @@
|
|||||||
|
// ─── Notification Category / Priority / TaskStatus ──────────────────────────
|
||||||
|
// Mirror Prisma enums for use in frontend/shared code
|
||||||
|
|
||||||
|
export type NotificationCategory =
|
||||||
|
| "NOTIFICATION"
|
||||||
|
| "REMINDER"
|
||||||
|
| "TASK"
|
||||||
|
| "APPROVAL";
|
||||||
|
|
||||||
|
export type NotificationPriority = "LOW" | "NORMAL" | "HIGH" | "URGENT";
|
||||||
|
|
||||||
|
export type TaskStatus = "OPEN" | "IN_PROGRESS" | "DONE" | "DISMISSED";
|
||||||
|
|
||||||
|
// ─── Task Action Helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface TaskActionDef {
|
||||||
|
action: string; // e.g. "approve_vacation"
|
||||||
|
entityId: string; // e.g. vacation ID
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a taskAction string like "approve_vacation:clxyz123" */
|
||||||
|
export function parseTaskAction(taskAction: string): TaskActionDef | null {
|
||||||
|
const idx = taskAction.indexOf(":");
|
||||||
|
if (idx < 0) return null;
|
||||||
|
return { action: taskAction.slice(0, idx), entityId: taskAction.slice(idx + 1) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a taskAction string */
|
||||||
|
export function buildTaskAction(action: string, entityId: string): string {
|
||||||
|
return `${action}:${entityId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Notification Interface ─────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface Notification {
|
export interface Notification {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -8,4 +42,50 @@ export interface Notification {
|
|||||||
entityType?: string | null;
|
entityType?: string | null;
|
||||||
readAt?: Date | null;
|
readAt?: Date | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
updatedAt?: Date;
|
||||||
|
|
||||||
|
// Extended fields
|
||||||
|
category?: NotificationCategory;
|
||||||
|
priority?: NotificationPriority;
|
||||||
|
link?: string | null;
|
||||||
|
|
||||||
|
// Task fields
|
||||||
|
taskStatus?: TaskStatus | null;
|
||||||
|
taskAction?: string | null;
|
||||||
|
assigneeId?: string | null;
|
||||||
|
dueDate?: Date | null;
|
||||||
|
completedAt?: Date | null;
|
||||||
|
completedBy?: string | null;
|
||||||
|
|
||||||
|
// Reminder fields
|
||||||
|
remindAt?: Date | null;
|
||||||
|
recurrence?: string | null;
|
||||||
|
nextRemindAt?: Date | null;
|
||||||
|
|
||||||
|
// Targeting
|
||||||
|
sourceId?: string | null;
|
||||||
|
senderId?: string | null;
|
||||||
|
channel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Broadcast Interface ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface NotificationBroadcast {
|
||||||
|
id: string;
|
||||||
|
senderId: string;
|
||||||
|
title: string;
|
||||||
|
body?: string | null;
|
||||||
|
link?: string | null;
|
||||||
|
category?: NotificationCategory;
|
||||||
|
priority?: NotificationPriority;
|
||||||
|
channel?: string;
|
||||||
|
|
||||||
|
targetType: string; // "user" | "role" | "project" | "orgUnit" | "all"
|
||||||
|
targetValue?: string | null;
|
||||||
|
|
||||||
|
scheduledAt?: Date | null;
|
||||||
|
sentAt?: Date | null;
|
||||||
|
recipientCount?: number;
|
||||||
|
|
||||||
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user