From 41189953198043333e807ac7ba4ff84bac474aed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Thu, 19 Mar 2026 20:43:36 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Sprint=201=20=E2=80=94=20staffing=20ass?= =?UTF-8?q?ign,=20dashboard=20cache,=20bulk=20ops,=20notifications?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Staffing "Assign" Button: - Inline assignment form on each suggestion card in StaffingPanel - Pre-fills project, dates, hours from search criteria - 1-click confirm creates allocation with PROPOSED status - Success/error toasts, removes assigned suggestions from list Dashboard Redis Caching: - New cache utility (packages/api/src/lib/cache.ts) with get/set/invalidate - All 5 dashboard queries wrapped with 60s TTL cache-aside pattern - Auto-invalidation on allocation + project mutations (fire-and-forget) - Graceful fallthrough to DB if Redis unavailable Bulk Operations: - CSV export for selected resources and projects (apps/web/src/lib/csv-export.ts) - Project batch delete mutation with cascade (assignments, demands, rules) - Export/Delete buttons added to BatchActionBar on both list pages Budget Overrun Notifications: - checkBudgetThresholds() alerts at 80% (HIGH) and 100% (URGENT) - Called after every allocation mutation, duplicate-safe - Targets ADMIN + MANAGER users with SSE delivery Estimate Approval Reminders: - checkPendingEstimateReminders() finds SUBMITTED versions > 3 days old - Cron endpoint: GET /api/cron/estimate-reminders (optional CRON_SECRET auth) - Creates in-app REMINDER notifications, duplicate-safe Co-Authored-By: claude-flow --- .../src/app/(app)/projects/ProjectsClient.tsx | 52 ++- .../app/(app)/resources/ResourcesClient.tsx | 24 +- .../app/api/cron/estimate-reminders/route.ts | 46 +++ .../dashboard/widgets/ChargeabilityWidget.tsx | 2 +- .../dashboard/widgets/TopValueWidget.tsx | 2 +- .../src/components/staffing/StaffingPanel.tsx | 381 +++++++++++++++--- apps/web/src/lib/csv-export.ts | 39 ++ packages/api/src/index.ts | 2 + packages/api/src/lib/budget-alerts.ts | 141 +++++++ packages/api/src/lib/cache.ts | 95 +++++ packages/api/src/lib/estimate-reminders.ts | 164 ++++++++ packages/api/src/router/allocation.ts | 49 +++ packages/api/src/router/dashboard.ts | 67 ++- packages/api/src/router/project.ts | 49 ++- 14 files changed, 1042 insertions(+), 71 deletions(-) create mode 100644 apps/web/src/app/api/cron/estimate-reminders/route.ts create mode 100644 apps/web/src/lib/csv-export.ts create mode 100644 packages/api/src/lib/budget-alerts.ts create mode 100644 packages/api/src/lib/cache.ts create mode 100644 packages/api/src/lib/estimate-reminders.ts diff --git a/apps/web/src/app/(app)/projects/ProjectsClient.tsx b/apps/web/src/app/(app)/projects/ProjectsClient.tsx index 073240b..6f99a20 100644 --- a/apps/web/src/app/(app)/projects/ProjectsClient.tsx +++ b/apps/web/src/app/(app)/projects/ProjectsClient.tsx @@ -10,6 +10,7 @@ import Image from "next/image"; import { clsx } from "clsx"; import { motion } from "framer-motion"; import { trpc } from "~/lib/trpc/client.js"; +import { generateCsv, downloadCsv } from "~/lib/csv-export.js"; import { ProjectModal } from "~/components/projects/ProjectModal.js"; import { ProjectWizard } from "~/components/projects/ProjectWizard.js"; import { useSelection } from "~/hooks/useSelection.js"; @@ -183,6 +184,7 @@ export function ProjectsClient() { const [openStatusProjectId, setOpenStatusProjectId] = useState(null); const [batchStatusPicker, setBatchStatusPicker] = useState(false); const [confirmBatchStatus, setConfirmBatchStatus] = useState<{ ids: string[]; status: string } | null>(null); + const [confirmBatchDelete, setConfirmBatchDelete] = useState(null); const selection = useSelection(); const utils = trpc.useUtils(); @@ -195,6 +197,13 @@ export function ProjectsClient() { }, }); + const batchDeleteMutation = trpc.project.batchDelete.useMutation({ + onSuccess: async () => { + await utils.project.listWithCosts.invalidate(); + selection.clear(); + }, + }); + // ─── Favorites ────────────────────────────────────────────────────────── const { data: favoriteIds } = trpc.user.getFavoriteProjectIds.useQuery(undefined, { staleTime: 30_000 }); const favSet = useMemo(() => new Set(favoriteIds ?? []), [favoriteIds]); @@ -336,6 +345,25 @@ export function ProjectsClient() { function closeModal() { setModalOpen(false); setEditingProject(null); } function clearAll() { setSearch(""); setStatusFilter(""); setOrderTypeFilter(""); } + const exportSelectedCsv = useCallback(() => { + const selected = projects.filter((p) => selection.selectedIds.has(p.id)); + if (selected.length === 0) return; + const csv = generateCsv(selected, [ + { header: "Short Code", accessor: (p) => p.shortCode }, + { header: "Name", accessor: (p) => p.name }, + { header: "Status", accessor: (p) => p.status }, + { header: "Order Type", accessor: (p) => p.orderType }, + { header: "Start Date", accessor: (p) => formatDate(p.startDate) }, + { header: "End Date", accessor: (p) => formatDate(p.endDate) }, + { header: "Budget (cents)", accessor: (p) => p.budgetCents }, + { header: "Win Probability", accessor: (p) => p.winProbability }, + { header: "Total Cost (cents)", accessor: (p) => p.totalCostCents }, + { header: "Person Days", accessor: (p) => p.totalPersonDays }, + { header: "Utilization %", accessor: (p) => p.utilizationPercent }, + ]); + downloadCsv(csv, `projects-export-${new Date().toISOString().slice(0, 10)}.csv`); + }, [projects, selection.selectedIds]); + const chips = [ ...(search ? [{ label: `Search: "${search}"`, onRemove: () => setSearch("") }] : []), ...(statusFilter ? [{ label: `Status: ${statusFilter}`, onRemove: () => setStatusFilter("") }] : []), @@ -682,12 +710,34 @@ export function ProjectsClient() { /> )} + {/* Confirm batch delete */} + {confirmBatchDelete && ( + { + batchDeleteMutation.mutate({ ids: confirmBatchDelete }); + setConfirmBatchDelete(null); + }} + onCancel={() => setConfirmBatchDelete(null)} + /> + )} + {/* Batch Action Bar */} setBatchStatusPicker(true) }, + { label: "Export Selected", onClick: exportSelectedCsv }, + { label: "Set Status...", onClick: () => setBatchStatusPicker(true) }, + { + label: `Delete (${selection.count})`, + variant: "danger" as const, + onClick: () => setConfirmBatchDelete(selection.selectedArray), + disabled: batchDeleteMutation.isPending, + }, ]} /> diff --git a/apps/web/src/app/(app)/resources/ResourcesClient.tsx b/apps/web/src/app/(app)/resources/ResourcesClient.tsx index dde850d..6eea6bc 100644 --- a/apps/web/src/app/(app)/resources/ResourcesClient.tsx +++ b/apps/web/src/app/(app)/resources/ResourcesClient.tsx @@ -8,6 +8,7 @@ import { RESOURCE_COLUMNS } from "@planarchy/shared"; import { BlueprintTarget, ResourceType } from "@planarchy/shared"; import { trpc } from "~/lib/trpc/client.js"; import { formatMoney } from "~/lib/format.js"; +import { generateCsv, downloadCsv } from "~/lib/csv-export.js"; import dynamic from "next/dynamic"; import { ResourceModal } from "~/components/resources/ResourceModal.js"; import { BulkEditModal } from "~/components/resources/BulkEditModal.js"; @@ -508,6 +509,22 @@ export function ResourcesClient() { return "Departed: no"; }, [departedFilter]); + const exportSelectedCsv = useCallback(() => { + const selected = displayedResources.filter((r) => selection.selectedIds.has(r.id)); + if (selected.length === 0) return; + const csv = generateCsv(selected, [ + { header: "EID", accessor: (r) => r.eid }, + { header: "Name", accessor: (r) => r.displayName }, + { header: "Email", accessor: (r) => r.email }, + { header: "Chapter", accessor: (r) => r.chapter ?? "" }, + { header: "LCR (cents)", accessor: (r) => r.lcrCents }, + { header: "Currency", accessor: (r) => r.currency }, + { header: "Chargeability Target", accessor: (r) => r.chargeabilityTarget }, + { header: "Active", accessor: (r) => r.isActive ? "Yes" : "No" }, + ]); + downloadCsv(csv, `resources-export-${new Date().toISOString().slice(0, 10)}.csv`); + }, [displayedResources, selection.selectedIds]); + const chips = [ ...(search ? [{ label: `Search: "${search}"`, onRemove: () => setSearch("") }] : []), ...(chapterFilter.length > 0 @@ -1429,10 +1446,15 @@ export function ResourcesClient() { count={selection.count} onClear={selection.clear} actions={[ + { + label: "Export Selected", + variant: "default" as const, + onClick: exportSelectedCsv, + }, ...(filterableFields.length > 0 ? [ { - label: "Edit Custom Fields", + label: "Bulk Edit", variant: "default" as const, onClick: () => setModal({ type: "bulkEdit" }), disabled: false, diff --git a/apps/web/src/app/api/cron/estimate-reminders/route.ts b/apps/web/src/app/api/cron/estimate-reminders/route.ts new file mode 100644 index 0000000..f55f82c --- /dev/null +++ b/apps/web/src/app/api/cron/estimate-reminders/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@planarchy/db"; +import { checkPendingEstimateReminders } from "@planarchy/api"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +/** + * GET /api/cron/estimate-reminders + * + * Scans for estimates that have been in SUBMITTED status for more than 3 days + * without approval and creates in-app reminder notifications for managers. + * + * Intended to be called by an external cron job (e.g. `curl http://host/api/cron/estimate-reminders`) + * or a scheduled task runner. + * + * Optionally protect this endpoint with a shared secret via the `CRON_SECRET` + * environment variable. When set, requests must include the header + * `Authorization: Bearer `. + */ +export async function GET(request: Request) { + const cronSecret = process.env["CRON_SECRET"]; + if (cronSecret) { + const auth = request.headers.get("authorization"); + if (auth !== `Bearer ${cronSecret}`) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + } + + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const reminderCount = await checkPendingEstimateReminders(prisma as any); + + return NextResponse.json({ + ok: true, + remindersCreated: reminderCount, + checkedAt: new Date().toISOString(), + }); + } catch (error) { + console.error("[cron/estimate-reminders] Error:", error); + return NextResponse.json( + { ok: false, error: "Internal error" }, + { status: 500 }, + ); + } +} diff --git a/apps/web/src/components/dashboard/widgets/ChargeabilityWidget.tsx b/apps/web/src/components/dashboard/widgets/ChargeabilityWidget.tsx index b151ac4..c418e47 100644 --- a/apps/web/src/components/dashboard/widgets/ChargeabilityWidget.tsx +++ b/apps/web/src/components/dashboard/widgets/ChargeabilityWidget.tsx @@ -160,7 +160,7 @@ export function ChargeabilityWidget({ config: _config }: WidgetProps) { const rawTop = data?.top ?? []; const rawWatch = data?.watchlist ?? []; - const month = data?.month ?? ""; + const month = (data?.month as string) ?? ""; const top = ([...rawTop] as ChargeabilityRow[]).sort((a, b) => { const mult = topDir === "asc" ? 1 : -1; diff --git a/apps/web/src/components/dashboard/widgets/TopValueWidget.tsx b/apps/web/src/components/dashboard/widgets/TopValueWidget.tsx index c058c2b..395091c 100644 --- a/apps/web/src/components/dashboard/widgets/TopValueWidget.tsx +++ b/apps/web/src/components/dashboard/widgets/TopValueWidget.tsx @@ -40,7 +40,7 @@ export function TopValueWidget({ config }: WidgetProps) { ); } - const list = data ?? []; + const list = (data ?? []) as Array<{ id: string; eid: string; displayName: string; chapter: string | null; lcrCents: number; valueScore: number | null }>; if (list.length === 0) { return ( diff --git a/apps/web/src/components/staffing/StaffingPanel.tsx b/apps/web/src/components/staffing/StaffingPanel.tsx index 5929152..a20e12b 100644 --- a/apps/web/src/components/staffing/StaffingPanel.tsx +++ b/apps/web/src/components/staffing/StaffingPanel.tsx @@ -1,10 +1,20 @@ "use client"; -import { useState } from "react"; +import { useState, useCallback } from "react"; import { trpc } from "~/lib/trpc/client.js"; +import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js"; +import { AllocationStatus } from "@planarchy/shared"; import { DateInput } from "~/components/ui/DateInput.js"; import { SkillTagInput } from "~/components/ui/SkillTagInput.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; +import { SuccessToast } from "~/components/ui/SuccessToast.js"; +import { Button } from "@planarchy/ui"; + +interface SearchCriteria { + startDate: string; + endDate: string; + hoursPerDay: number; +} export function StaffingPanel() { const [requiredSkills, setRequiredSkills] = useState(["TypeScript", "React"]); @@ -18,6 +28,14 @@ export function StaffingPanel() { }); const [hoursPerDay, setHoursPerDay] = useState(8); const [submitted, setSubmitted] = useState(false); + const [assignedIds, setAssignedIds] = useState>(new Set()); + const [toast, setToast] = useState<{ show: boolean; message: string; variant: "success" | "warning" }>({ + show: false, + message: "", + variant: "success", + }); + + const clearToast = useCallback(() => setToast((t) => ({ ...t, show: false })), []); const { data: suggestions, isLoading } = trpc.staffing.getSuggestions.useQuery( { @@ -29,8 +47,19 @@ export function StaffingPanel() { { enabled: submitted }, ); + const visibleSuggestions = suggestions?.filter((s) => !assignedIds.has(s.resourceId)); + + const handleAssigned = useCallback((resourceId: string, resourceName: string) => { + setAssignedIds((prev) => new Set(prev).add(resourceId)); + setToast({ show: true, message: `${resourceName} assigned successfully`, variant: "success" }); + }, []); + + const searchCriteria: SearchCriteria = { startDate, endDate, hoursPerDay }; + return (
+ +
@@ -63,7 +92,7 @@ export function StaffingPanel() {
@@ -134,62 +163,23 @@ export function StaffingPanel() {
)} - {suggestions && suggestions.length === 0 && ( + {visibleSuggestions && visibleSuggestions.length === 0 && (
- No resources found matching your criteria. + {assignedIds.size > 0 ? "All suggestions have been assigned." : "No resources found matching your criteria."}
)} - {suggestions && suggestions.length > 0 && ( + {visibleSuggestions && visibleSuggestions.length > 0 && (
- {suggestions.map((suggestion, idx) => ( -
-
-
-
- {idx + 1} -
-
-
{suggestion.resourceName}
-
{suggestion.eid}
-
-
-
-
Match Score
-
{suggestion.score}
-
-
- -
- - - - -
- -
- {suggestion.matchedSkills.map((skill) => ( - - {skill} - - ))} - {suggestion.missingSkills.map((skill) => ( - - {skill} missing - - ))} -
- -
- LCR: {(suggestion.estimatedDailyCostCents / 100 / 8).toFixed(0)} €/h - Utilization: {Math.round(suggestion.currentUtilization)}% - {suggestion.availabilityConflicts.length > 0 && ( - - {suggestion.availabilityConflicts.length} scheduling conflict{suggestion.availabilityConflicts.length === 1 ? "" : "s"} - - )} -
-
+ {visibleSuggestions.map((suggestion, idx) => ( + setToast({ show: true, message: msg, variant: "warning" })} + /> ))}
)} @@ -210,6 +200,291 @@ export function StaffingPanel() { ); } +/* -------------------------------------------------------------------------- */ +/* Suggestion Card */ +/* -------------------------------------------------------------------------- */ + +interface SuggestionLike { + resourceId: string; + resourceName: string; + eid: string; + score: number; + scoreBreakdown: { + skillScore: number; + availabilityScore: number; + costScore: number; + utilizationScore: number; + }; + matchedSkills: string[]; + missingSkills: string[]; + availabilityConflicts: string[]; + estimatedDailyCostCents: number; + currentUtilization: number; +} + +interface SuggestionCardProps { + suggestion: SuggestionLike; + rank: number; + searchCriteria: SearchCriteria; + onAssigned: (resourceId: string, resourceName: string) => void; + onError: (message: string) => void; +} + +function SuggestionCard({ suggestion, rank, searchCriteria, onAssigned, onError }: SuggestionCardProps) { + const [expanded, setExpanded] = useState(false); + + return ( +
+
+
+
+ {rank} +
+
+
{suggestion.resourceName}
+
{suggestion.eid}
+
+
+
+ +
+
Match Score
+
{suggestion.score}
+
+
+
+ +
+ + + + +
+ +
+ {suggestion.matchedSkills.map((skill) => ( + + {skill} + + ))} + {suggestion.missingSkills.map((skill) => ( + + {skill} missing + + ))} +
+ +
+ LCR: {(suggestion.estimatedDailyCostCents / 100 / 8).toFixed(0)} EUR/h + Utilization: {Math.round(suggestion.currentUtilization)}% + {suggestion.availabilityConflicts.length > 0 && ( + + {suggestion.availabilityConflicts.length} scheduling conflict{suggestion.availabilityConflicts.length === 1 ? "" : "s"} + + )} +
+ + {expanded && ( + onAssigned(suggestion.resourceId, suggestion.resourceName)} + onError={onError} + onCancel={() => setExpanded(false)} + /> + )} +
+ ); +} + +/* -------------------------------------------------------------------------- */ +/* Inline Assign Form */ +/* -------------------------------------------------------------------------- */ + +interface AssignFormProps { + resourceId: string; + resourceName: string; + searchCriteria: SearchCriteria; + onAssigned: () => void; + onError: (message: string) => void; + onCancel: () => void; +} + +function AssignForm({ resourceId, resourceName, searchCriteria, onAssigned, onError, onCancel }: AssignFormProps) { + const [projectId, setProjectId] = useState(""); + const [assignStartDate, setAssignStartDate] = useState(searchCriteria.startDate); + const [assignEndDate, setAssignEndDate] = useState(searchCriteria.endDate); + const [assignHours, setAssignHours] = useState(searchCriteria.hoursPerDay); + const [roleId, setRoleId] = useState(""); + const [roleFreeText, setRoleFreeText] = useState(""); + + const invalidatePlanningViews = useInvalidatePlanningViews(); + + const { data: projects } = trpc.project.list.useQuery( + { limit: 500 }, + { staleTime: 60_000 }, + ); + const { data: roles } = trpc.role.list.useQuery( + { isActive: true }, + { staleTime: 60_000 }, + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const createAssignment = (trpc.allocation.createAssignment.useMutation as any)({ + onSuccess: () => { + invalidatePlanningViews(); + onAssigned(); + }, + onError: (err: { message: string }) => { + onError(err.message || "Failed to create assignment"); + }, + }) as { mutate: (input: unknown) => void; isPending: boolean }; + + const canSubmit = projectId && assignStartDate && assignEndDate && assignHours > 0; + + const handleSubmit = () => { + if (!canSubmit) return; + createAssignment.mutate({ + resourceId, + projectId, + startDate: new Date(assignStartDate), + endDate: new Date(assignEndDate), + hoursPerDay: assignHours, + percentage: 100, + ...(roleId ? { roleId } : {}), + ...(roleFreeText ? { role: roleFreeText } : {}), + status: AllocationStatus.PROPOSED, + metadata: {}, + }); + }; + + const projectList = (projects as { projects?: Array<{ id: string; name: string; shortCode?: string | null }> } | undefined)?.projects ?? []; + const rolesList = (roles ?? []) as Array<{ id: string; name: string }>; + const selectedProject = projectList.find((p) => p.id === projectId); + + return ( +
+
+

+ Assign {resourceName} +

+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + setAssignHours(Number(e.target.value))} + min={0.5} + max={24} + step={0.5} + className="app-input" + /> +
+ +
+ + {rolesList.length > 0 ? ( + + ) : ( + setRoleFreeText(e.target.value)} + placeholder="e.g. 3D Artist" + className="app-input" + /> + )} +
+
+ + {selectedProject && ( +
+ Assigning to {selectedProject.name} from {assignStartDate} to {assignEndDate} at {assignHours}h/day +
+ )} + +
+ + +
+
+ ); +} + +/* -------------------------------------------------------------------------- */ +/* Score Bar */ +/* -------------------------------------------------------------------------- */ + function ScoreBar({ label, value, tooltip }: { label: string; value: number; tooltip?: string }) { return (
diff --git a/apps/web/src/lib/csv-export.ts b/apps/web/src/lib/csv-export.ts new file mode 100644 index 0000000..ca493e9 --- /dev/null +++ b/apps/web/src/lib/csv-export.ts @@ -0,0 +1,39 @@ +/** + * Generic CSV export utility. + * Generates a CSV string from an array of objects and triggers a download. + */ + +function escapeCsvValue(value: unknown): string { + if (value == null) return ""; + const str = String(value); + // Wrap in quotes if it contains comma, quote, or newline + if (str.includes(",") || str.includes('"') || str.includes("\n")) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; +} + +interface CsvColumn { + header: string; + accessor: (row: T) => unknown; +} + +export function generateCsv(rows: T[], columns: CsvColumn[]): string { + const header = columns.map((c) => escapeCsvValue(c.header)).join(","); + const body = rows + .map((row) => columns.map((col) => escapeCsvValue(col.accessor(row))).join(",")) + .join("\n"); + return `${header}\n${body}`; +} + +export function downloadCsv(csvContent: string, filename: string): void { + const blob = new Blob(["\uFEFF" + csvContent], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index bd82c2c..0baac46 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -2,3 +2,5 @@ export { appRouter, type AppRouter } from "./router/index.js"; export { createTRPCContext, createTRPCRouter, createCallerFactory, publicProcedure, protectedProcedure, managerProcedure, controllerProcedure, adminProcedure, requirePermission, loadRoleDefaults, invalidateRoleDefaultsCache } from "./trpc.js"; export { eventBus, emitAllocationCreated, emitAllocationUpdated, emitAllocationDeleted, emitProjectShifted, emitBudgetWarning, flushPendingEvents, cancelPendingEvents } from "./sse/event-bus.js"; export { anonymizeResource, anonymizeResources, anonymizeUser, getAnonymizationConfig, getAnonymizationDirectory } from "./lib/anonymization.js"; +export { checkBudgetThresholds } from "./lib/budget-alerts.js"; +export { checkPendingEstimateReminders } from "./lib/estimate-reminders.js"; diff --git a/packages/api/src/lib/budget-alerts.ts b/packages/api/src/lib/budget-alerts.ts new file mode 100644 index 0000000..9ab9572 --- /dev/null +++ b/packages/api/src/lib/budget-alerts.ts @@ -0,0 +1,141 @@ +import { listAssignmentBookings } from "@planarchy/application"; +import { emitNotificationCreated } from "../sse/event-bus.js"; + +type DbClient = Parameters[0] & { + project: { + findUnique: (args: { + where: { id: string }; + select: { id: true; name: true; shortCode: true; budgetCents: true }; + }) => Promise<{ + id: string; + name: string; + shortCode: string; + budgetCents: number; + } | null>; + }; + notification: { + findFirst: (args: { + where: { + entityId: string; + entityType: string; + type: string; + }; + select: { id: true }; + }) => Promise<{ id: string } | null>; + create: (args: { + data: { + userId: string; + type: string; + category: string; + priority: string; + title: string; + body: string; + entityId: string; + entityType: string; + link: string; + channel: string; + }; + }) => Promise<{ id: string; userId: string }>; + }; + user: { + findMany: (args: { + where: { systemRole: { in: string[] } }; + select: { id: true }; + }) => Promise>; + }; +}; + +const THRESHOLDS = [ + { percent: 100, type: "BUDGET_OVERRUN_100", label: "100%", priority: "URGENT" as const }, + { percent: 80, type: "BUDGET_OVERRUN_80", label: "80%", priority: "HIGH" as const }, +] as const; + +/** + * Check whether a project's current spend has crossed 80% or 100% of its budget. + * Creates in-app notifications for all managers/admins when a threshold is + * crossed for the first time. + * + * Safe to call repeatedly -- duplicate notifications are prevented by checking + * whether a notification with the same entityId + type already exists. + */ +export async function checkBudgetThresholds( + db: DbClient, + projectId: string, +): Promise { + const project = await db.project.findUnique({ + where: { id: projectId }, + select: { id: true, name: true, shortCode: true, budgetCents: true }, + }); + + if (!project || project.budgetCents <= 0) return; + + // Compute total spend from assignment bookings (same logic as listWithCosts) + const bookings = await listAssignmentBookings(db, { + startDate: new Date("1900-01-01T00:00:00.000Z"), + endDate: new Date("2100-12-31T23:59:59.999Z"), + projectIds: [projectId], + }); + + let totalCostCents = 0; + for (const booking of bookings) { + const days = + (new Date(booking.endDate).getTime() - + new Date(booking.startDate).getTime()) / + (1000 * 60 * 60 * 24) + + 1; + totalCostCents += booking.dailyCostCents * days; + } + totalCostCents = Math.round(totalCostCents); + + const spendPercent = (totalCostCents / project.budgetCents) * 100; + + for (const threshold of THRESHOLDS) { + if (spendPercent < threshold.percent) continue; + + // Check if we already sent this alert + const existing = await db.notification.findFirst({ + where: { + entityId: projectId, + entityType: "project_budget", + type: threshold.type, + }, + select: { id: true }, + }); + + if (existing) continue; + + // Get all managers and admins + const managers = await db.user.findMany({ + where: { systemRole: { in: ["ADMIN", "MANAGER"] } }, + select: { id: true }, + }); + + const formattedSpend = (totalCostCents / 100).toLocaleString("de-DE", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + const formattedBudget = (project.budgetCents / 100).toLocaleString( + "de-DE", + { minimumFractionDigits: 2, maximumFractionDigits: 2 }, + ); + + for (const manager of managers) { + const notification = await db.notification.create({ + data: { + userId: manager.id, + type: threshold.type, + category: "NOTIFICATION", + priority: threshold.priority, + title: `Budget alert: ${project.name} has reached ${threshold.label} of budget`, + body: `Project ${project.shortCode} "${project.name}" has spent ${formattedSpend} EUR of ${formattedBudget} EUR budget (${Math.round(spendPercent)}%).`, + entityId: projectId, + entityType: "project_budget", + link: `/projects/${projectId}`, + channel: "in_app", + }, + }); + + emitNotificationCreated(manager.id, notification.id); + } + } +} diff --git a/packages/api/src/lib/cache.ts b/packages/api/src/lib/cache.ts new file mode 100644 index 0000000..5b24d12 --- /dev/null +++ b/packages/api/src/lib/cache.ts @@ -0,0 +1,95 @@ +import { Redis } from "ioredis"; + +const REDIS_URL = process.env["REDIS_URL"] ?? "redis://localhost:6380"; +const KEY_PREFIX = "dashboard:"; +const DEFAULT_TTL_SECONDS = 60; + +let redis: Redis | null = null; + +function getRedis(): Redis { + if (!redis) { + redis = new Redis(REDIS_URL, { + lazyConnect: false, + enableReadyCheck: false, + // Don't let cache operations block the app if Redis is slow + commandTimeout: 2000, + }); + redis.on("error", (e: unknown) => { + console.error("[Redis cache]", e); + }); + } + return redis; +} + +/** + * Retrieve a cached value by key. + * Returns null on cache miss or if Redis is unavailable. + */ +export async function cacheGet(key: string): Promise { + try { + const raw = await getRedis().get(`${KEY_PREFIX}${key}`); + if (raw === null) return null; + return JSON.parse(raw) as T; + } catch { + // Redis down or parse error — fall through to DB + return null; + } +} + +/** + * Store a value in the cache with a TTL. + * Silently ignores errors when Redis is unavailable. + */ +export async function cacheSet( + key: string, + value: unknown, + ttlSeconds: number = DEFAULT_TTL_SECONDS, +): Promise { + try { + await getRedis().set( + `${KEY_PREFIX}${key}`, + JSON.stringify(value), + "EX", + ttlSeconds, + ); + } catch { + // Redis down — silently ignore, data will be served from DB next time + } +} + +/** + * Delete all keys matching a glob pattern (e.g. "dashboard:*"). + * The pattern is automatically prefixed with the KEY_PREFIX unless it already starts with it. + */ +export async function cacheInvalidate(pattern: string): Promise { + try { + const fullPattern = pattern.startsWith(KEY_PREFIX) + ? pattern + : `${KEY_PREFIX}${pattern}`; + const r = getRedis(); + let cursor = "0"; + do { + const [nextCursor, keys] = await r.scan( + cursor, + "MATCH", + fullPattern, + "COUNT", + 100, + ); + cursor = nextCursor; + if (keys.length > 0) { + await r.del(...keys); + } + } while (cursor !== "0"); + } catch { + // Redis down — nothing to invalidate + } +} + +/** + * Invalidate all dashboard cache entries. + * Convenience wrapper used from mutation hooks. + */ +export async function invalidateDashboardCache(): Promise { + await cacheInvalidate("*"); +} diff --git a/packages/api/src/lib/estimate-reminders.ts b/packages/api/src/lib/estimate-reminders.ts new file mode 100644 index 0000000..56cd182 --- /dev/null +++ b/packages/api/src/lib/estimate-reminders.ts @@ -0,0 +1,164 @@ +import { emitNotificationCreated } from "../sse/event-bus.js"; + +type DbClient = { + estimate: { + findMany: (args: { + where: { + versions: { + some: { + status: string; + submittedAt: { lte: Date }; + }; + }; + }; + select: { + id: true; + name: true; + projectId: true; + versions: { + where: { status: string }; + select: { id: true; versionNumber: true; submittedAt: true }; + orderBy: { versionNumber: "desc" }; + take: 1; + }; + }; + }) => Promise< + Array<{ + id: string; + name: string; + projectId: string | null; + versions: Array<{ + id: string; + versionNumber: number; + submittedAt: Date | null; + }>; + }> + >; + }; + notification: { + findFirst: (args: { + where: { + entityId: string; + entityType: string; + type: string; + }; + select: { id: true }; + }) => Promise<{ id: string } | null>; + create: (args: { + data: { + userId: string; + type: string; + category: string; + priority: string; + title: string; + body: string; + entityId: string; + entityType: string; + link: string; + channel: string; + }; + }) => Promise<{ id: string; userId: string }>; + }; + user: { + findMany: (args: { + where: { systemRole: { in: string[] } }; + select: { id: true }; + }) => Promise>; + }; +}; + +const REMINDER_DAYS = 3; + +/** + * Find all estimates that have a version in SUBMITTED status for longer than + * REMINDER_DAYS days and create a single reminder notification per estimate + * for all managers/admins. + * + * Returns the number of new reminders created. + */ +export async function checkPendingEstimateReminders( + db: DbClient, +): Promise { + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - REMINDER_DAYS); + + const pendingEstimates = await db.estimate.findMany({ + where: { + versions: { + some: { + status: "SUBMITTED", + submittedAt: { lte: cutoff }, + }, + }, + }, + select: { + id: true, + name: true, + projectId: true, + versions: { + where: { status: "SUBMITTED" }, + select: { id: true, versionNumber: true, submittedAt: true }, + orderBy: { versionNumber: "desc" }, + take: 1, + }, + }, + }); + + if (pendingEstimates.length === 0) return 0; + + const managers = await db.user.findMany({ + where: { systemRole: { in: ["ADMIN", "MANAGER"] } }, + select: { id: true }, + }); + + if (managers.length === 0) return 0; + + let reminderCount = 0; + + for (const estimate of pendingEstimates) { + const version = estimate.versions[0]; + if (!version) continue; + + // Check if we already sent a reminder for this version + const existing = await db.notification.findFirst({ + where: { + entityId: version.id, + entityType: "estimate_approval_reminder", + type: "ESTIMATE_APPROVAL_REMINDER", + }, + select: { id: true }, + }); + + if (existing) continue; + + const daysPending = version.submittedAt + ? Math.floor( + (Date.now() - new Date(version.submittedAt).getTime()) / + (1000 * 60 * 60 * 24), + ) + : REMINDER_DAYS; + + for (const manager of managers) { + const notification = await db.notification.create({ + data: { + userId: manager.id, + type: "ESTIMATE_APPROVAL_REMINDER", + category: "REMINDER", + priority: "HIGH", + title: `Estimate awaiting approval: ${estimate.name} (v${version.versionNumber})`, + body: `Estimate "${estimate.name}" version ${version.versionNumber} has been pending approval for ${daysPending} days.`, + entityId: version.id, + entityType: "estimate_approval_reminder", + link: `/estimates/${estimate.id}`, + channel: "in_app", + }, + }); + + emitNotificationCreated(manager.id, notification.id); + } + + reminderCount++; + } + + return reminderCount; +} diff --git a/packages/api/src/router/allocation.ts b/packages/api/src/router/allocation.ts index ae809bd..f35fe6e 100644 --- a/packages/api/src/router/allocation.ts +++ b/packages/api/src/router/allocation.ts @@ -29,7 +29,9 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { anonymizeResource, getAnonymizationDirectory } from "../lib/anonymization.js"; +import { checkBudgetThresholds } from "../lib/budget-alerts.js"; import { emitAllocationCreated, emitAllocationDeleted, emitAllocationUpdated, emitNotificationCreated } from "../sse/event-bus.js"; +import { invalidateDashboardCache } from "../lib/cache.js"; import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js"; import { PROJECT_BRIEF_SELECT, RESOURCE_BRIEF_SELECT, ROLE_BRIEF_SELECT } from "../db/selects.js"; @@ -242,6 +244,9 @@ export const allocationRouter = createTRPCRouter({ projectId: allocation.projectId, resourceId: allocation.resourceId, }); + void invalidateDashboardCache(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + void checkBudgetThresholds(ctx.db as any, allocation.projectId); return allocation; }), @@ -445,6 +450,7 @@ export const allocationRouter = createTRPCRouter({ projectId: demandRequirement.projectId, resourceId: null, }); + void invalidateDashboardCache(); // Create staffing tasks for managers const [project, roleEntity, managers] = await Promise.all([ @@ -487,6 +493,8 @@ export const allocationRouter = createTRPCRouter({ emitNotificationCreated(manager.id, task.id); } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + void checkBudgetThresholds(ctx.db as any, demandRequirement.projectId); return demandRequirement; }), @@ -508,6 +516,9 @@ export const allocationRouter = createTRPCRouter({ projectId: updated.projectId, resourceId: null, }); + void invalidateDashboardCache(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + void checkBudgetThresholds(ctx.db as any, updated.projectId); return updated; }), @@ -529,6 +540,9 @@ export const allocationRouter = createTRPCRouter({ projectId: assignment.projectId, resourceId: assignment.resourceId, }); + void invalidateDashboardCache(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + void checkBudgetThresholds(ctx.db as any, assignment.projectId); return assignment; }), @@ -551,6 +565,9 @@ export const allocationRouter = createTRPCRouter({ projectId: updated.projectId, resourceId: updated.resourceId, }); + void invalidateDashboardCache(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + void checkBudgetThresholds(ctx.db as any, updated.projectId); return updated; }), @@ -585,6 +602,9 @@ export const allocationRouter = createTRPCRouter({ }); emitAllocationDeleted(existing.id, existing.projectId); + void invalidateDashboardCache(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + void checkBudgetThresholds(ctx.db as any, existing.projectId); return { success: true }; }), @@ -607,6 +627,9 @@ export const allocationRouter = createTRPCRouter({ projectId: result.updatedDemandRequirement.projectId, resourceId: null, }); + void invalidateDashboardCache(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + void checkBudgetThresholds(ctx.db as any, result.assignment.projectId); return result; }), @@ -623,6 +646,9 @@ export const allocationRouter = createTRPCRouter({ if (result.updatedAllocation) { emitAllocationUpdated(result.updatedAllocation); } + void invalidateDashboardCache(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + void checkBudgetThresholds(ctx.db as any, result.createdAllocation.projectId as string); return result; }), @@ -665,6 +691,9 @@ export const allocationRouter = createTRPCRouter({ projectId: updated.projectId, resourceId: updated.resourceId, }); + void invalidateDashboardCache(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + void checkBudgetThresholds(ctx.db as any, updated.projectId); return updated; }), @@ -699,6 +728,9 @@ export const allocationRouter = createTRPCRouter({ }); emitAllocationDeleted(existing.id, existing.projectId); + void invalidateDashboardCache(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + void checkBudgetThresholds(ctx.db as any, existing.projectId); return { success: true }; }), @@ -726,6 +758,9 @@ export const allocationRouter = createTRPCRouter({ }); emitAllocationDeleted(existing.entry.id, existing.projectId); + void invalidateDashboardCache(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + void checkBudgetThresholds(ctx.db as any, existing.projectId); return { success: true }; }), @@ -760,6 +795,13 @@ export const allocationRouter = createTRPCRouter({ for (const a of existing) { emitAllocationDeleted(a.entry.id, a.projectId); } + void invalidateDashboardCache(); + // Check budget thresholds for each affected project + const affectedProjectIds = [...new Set(existing.map((a) => a.projectId))]; + for (const pid of affectedProjectIds) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + void checkBudgetThresholds(ctx.db as any, pid); + } return { count: existing.length }; }), @@ -804,6 +846,13 @@ export const allocationRouter = createTRPCRouter({ for (const a of updated) { emitAllocationUpdated({ id: a.id, projectId: a.projectId, resourceId: a.resourceId }); } + void invalidateDashboardCache(); + // Check budget thresholds for each affected project + const affectedProjectIds = [...new Set(updated.map((a) => a.projectId))]; + for (const pid of affectedProjectIds) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + void checkBudgetThresholds(ctx.db as any, pid); + } return { count: updated.length }; }), diff --git a/packages/api/src/router/dashboard.ts b/packages/api/src/router/dashboard.ts index 7419a82..e9b9136 100644 --- a/packages/api/src/router/dashboard.ts +++ b/packages/api/src/router/dashboard.ts @@ -8,9 +8,20 @@ import { getDashboardTopValueResources, } from "@planarchy/application"; import { anonymizeResources, getAnonymizationDirectory } from "../lib/anonymization.js"; +import { cacheGet, cacheSet } from "../lib/cache.js"; + +const DEFAULT_TTL = 60; // seconds export const dashboardRouter = createTRPCRouter({ - getOverview: protectedProcedure.query(({ ctx }) => getDashboardOverview(ctx.db)), + getOverview: protectedProcedure.query(async ({ ctx }) => { + const cacheKey = "overview"; + const cached = await cacheGet>>(cacheKey); + if (cached) return cached; + + const result = await getDashboardOverview(ctx.db); + await cacheSet(cacheKey, result, DEFAULT_TTL); + return result; + }), getPeakTimes: protectedProcedure .input( @@ -21,27 +32,40 @@ export const dashboardRouter = createTRPCRouter({ groupBy: z.enum(["project", "chapter", "resource"]).default("project"), }), ) - .query(({ ctx, input }) => - getDashboardPeakTimes(ctx.db, { + .query(async ({ ctx, input }) => { + const cacheKey = `peakTimes:${input.startDate}:${input.endDate}:${input.granularity}:${input.groupBy}`; + const cached = await cacheGet>>(cacheKey); + if (cached) return cached; + + const result = await getDashboardPeakTimes(ctx.db, { startDate: new Date(input.startDate), endDate: new Date(input.endDate), granularity: input.granularity, groupBy: input.groupBy, - }), - ), + }); + await cacheSet(cacheKey, result, DEFAULT_TTL); + return result; + }), getTopValueResources: protectedProcedure .input(z.object({ limit: z.number().int().min(1).max(50).default(10) })) .query(async ({ ctx, input }) => { + const userRole = + (ctx.session.user as { role?: string } | undefined)?.role ?? "USER"; + const cacheKey = `topValue:${input.limit}:${userRole}`; + const cached = await cacheGet>>(cacheKey); + if (cached) return cached; + const [resources, directory] = await Promise.all([ getDashboardTopValueResources(ctx.db, { limit: input.limit, - userRole: - (ctx.session.user as { role?: string } | undefined)?.role ?? "USER", + userRole, }), getAnonymizationDirectory(ctx.db), ]); - return anonymizeResources(resources, directory); + const result = anonymizeResources(resources, directory); + await cacheSet(cacheKey, result, DEFAULT_TTL); + return result; }), getDemand: protectedProcedure @@ -52,13 +76,19 @@ export const dashboardRouter = createTRPCRouter({ groupBy: z.enum(["project", "person", "chapter"]).default("project"), }), ) - .query(({ ctx, input }) => - getDashboardDemand(ctx.db, { + .query(async ({ ctx, input }) => { + const cacheKey = `demand:${input.startDate}:${input.endDate}:${input.groupBy}`; + const cached = await cacheGet>>(cacheKey); + if (cached) return cached; + + const result = await getDashboardDemand(ctx.db, { startDate: new Date(input.startDate), endDate: new Date(input.endDate), groupBy: input.groupBy, - }), - ), + }); + await cacheSet(cacheKey, result, DEFAULT_TTL); + return result; + }), getChargeabilityOverview: controllerProcedure .input( @@ -71,6 +101,15 @@ export const dashboardRouter = createTRPCRouter({ }), ) .query(async ({ ctx, input }) => { + const cacheKey = `chargeability:${input.includeProposed}:${input.topN}:${input.watchlistThreshold}:${(input.countryIds ?? []).join(",")}:${input.departed ?? ""}`; + type ChargeResult = Awaited>; + const cached = await cacheGet<{ + top: unknown[]; + watchlist: unknown[]; + [key: string]: unknown; + }>(cacheKey); + if (cached) return cached; + const [overview, directory] = await Promise.all([ getDashboardChargeabilityOverview(ctx.db, { includeProposed: input.includeProposed, @@ -82,10 +121,12 @@ export const dashboardRouter = createTRPCRouter({ getAnonymizationDirectory(ctx.db), ]); - return { + const result = { ...overview, top: anonymizeResources(overview.top, directory), watchlist: anonymizeResources(overview.watchlist, directory), }; + await cacheSet(cacheKey, result, DEFAULT_TTL); + return result; }), }); diff --git a/packages/api/src/router/project.ts b/packages/api/src/router/project.ts index 7cfce0f..0f8d742 100644 --- a/packages/api/src/router/project.ts +++ b/packages/api/src/router/project.ts @@ -12,6 +12,7 @@ import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js"; import { loadProjectPlanningReadModel } from "./project-planning-read-model.js"; import { adminProcedure, controllerProcedure, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js"; import { createDalleClient, isDalleConfigured, parseAiError } from "../ai-client.js"; +import { invalidateDashboardCache } from "../lib/cache.js"; const MAX_COVER_SIZE = 4 * 1024 * 1024; // 4 MB base64 string length limit (client compresses before upload) @@ -155,6 +156,7 @@ export const projectRouter = createTRPCRouter({ }, }); + void invalidateDashboardCache(); return project; }), @@ -207,6 +209,7 @@ export const projectRouter = createTRPCRouter({ }, }); + void invalidateDashboardCache(); return updated; }), @@ -214,10 +217,12 @@ export const projectRouter = createTRPCRouter({ .input(z.object({ id: z.string(), status: z.nativeEnum(ProjectStatus) })) .mutation(async ({ ctx, input }) => { requirePermission(ctx, PermissionKey.MANAGE_PROJECTS); - return ctx.db.project.update({ + const result = await ctx.db.project.update({ where: { id: input.id }, data: { status: input.status }, }); + void invalidateDashboardCache(); + return result; }), batchUpdateStatus: managerProcedure @@ -244,6 +249,7 @@ export const projectRouter = createTRPCRouter({ }, }); + void invalidateDashboardCache(); return { count: updated.length }; }), @@ -349,9 +355,50 @@ export const projectRouter = createTRPCRouter({ }); }); + void invalidateDashboardCache(); return { id: input.id, name: project.name }; }), + batchDelete: adminProcedure + .input( + z.object({ + ids: z.array(z.string()).min(1).max(50), + }), + ) + .mutation(async ({ ctx, input }) => { + const projects = await ctx.db.project.findMany({ + where: { id: { in: input.ids } }, + select: { id: true, name: true, shortCode: true }, + }); + + if (projects.length === 0) { + throw new TRPCError({ code: "NOT_FOUND", message: "No projects found" }); + } + + await ctx.db.$transaction(async (tx) => { + const ids = projects.map((p) => p.id); + await tx.assignment.deleteMany({ where: { projectId: { in: ids } } }); + await tx.demandRequirement.deleteMany({ where: { projectId: { in: ids } } }); + await tx.calculationRule.updateMany({ + where: { projectId: { in: ids } }, + data: { projectId: null }, + }); + await tx.project.deleteMany({ where: { id: { in: ids } } }); + await tx.auditLog.create({ + data: { + entityType: "Project", + entityId: ids.join(","), + action: "DELETE", + ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), + changes: { before: projects } as never, + }, + }); + }); + + void invalidateDashboardCache(); + return { count: projects.length }; + }), + // ─── Cover Art ────────────────────────────────────────────────────────────── generateCover: managerProcedure