From ac845d72b735a03b4f37d76d8ef00f4d561d52dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Sun, 22 Mar 2026 21:50:39 +0100 Subject: [PATCH] refactor: deduplicate modals, notifications, confirms, comboboxes, proficiency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modal Overlay (Finding 1 — 6 admin files): - Migrated CountriesClient, ManagementLevelsClient, OrgUnitsClient, CalculationRulesClient, UtilizationCategoriesClient, RoleModal from inline fixed-overlay to AnimatedModal component - Gains: animated transitions, backdrop blur, escape key for free Notification Helper (Finding 9 — 9 API files, 14 call sites): - New createNotification() + createNotificationsForUsers() in packages/api/src/lib/create-notification.ts - Handles exactOptionalPropertyTypes spread + SSE emit internally - Simplified: budget-alerts, estimate-reminders, auto-staffing, vacation-conflicts, chargeability-alerts, comment, vacation, notification ConfirmDialog (Finding 3 — 11 files): - Replaced all window.confirm() calls with ConfirmDialog component - Files: CommentThread, EffortRules, ExperienceMultipliers, ManagementLevels, CalculationRules, Countries, RateCards, ApplyEffortRules, ApplyExperienceMultipliers, NotificationCenter, ReminderModal EntityCombobox (Finding 4 — 3 files): - New generic EntityCombobox with customization hooks - ResourceCombobox + ProjectCombobox rewritten as thin wrappers - All consumers unchanged (backwards-compatible props) Proficiency Constants (Finding 2 — 2 files): - SkillsAnalytics + SkillMarketplace now import from skills/shared.tsx - Deleted ~70 LOC of local duplicate definitions Regression: 283 engine + 37 staffing tests pass. TypeScript clean. AI Assistant: all 87 tools verified accessible. Co-Authored-By: claude-flow --- .../admin/CalculationRulesClient.tsx | 29 +++- .../src/components/admin/CountriesClient.tsx | 31 ++-- .../components/admin/EffortRulesClient.tsx | 22 ++- .../admin/ExperienceMultipliersClient.tsx | 22 ++- .../admin/ManagementLevelsClient.tsx | 41 +++-- .../src/components/admin/OrgUnitsClient.tsx | 11 +- .../src/components/admin/RateCardsClient.tsx | 37 ++++- .../admin/UtilizationCategoriesClient.tsx | 11 +- .../components/analytics/SkillMarketplace.tsx | 56 +------ .../components/analytics/SkillsAnalytics.tsx | 25 +-- .../src/components/comments/CommentThread.tsx | 22 ++- .../components/estimates/ApplyEffortRules.tsx | 20 ++- .../estimates/ApplyExperienceMultipliers.tsx | 19 ++- .../NotificationCenterClient.tsx | 22 ++- .../notifications/ReminderModal.tsx | 21 ++- apps/web/src/components/roles/RoleModal.tsx | 24 +-- apps/web/src/components/ui/EntityCombobox.tsx | 136 ++++++++++++++++ .../web/src/components/ui/ProjectCombobox.tsx | 150 +++++------------ .../src/components/ui/ResourceCombobox.tsx | 151 +++++------------- packages/api/src/index.ts | 1 + packages/api/src/lib/auto-staffing.ts | 33 ++-- packages/api/src/lib/budget-alerts.ts | 33 ++-- packages/api/src/lib/chargeability-alerts.ts | 33 ++-- packages/api/src/lib/create-notification.ts | 102 ++++++++++++ packages/api/src/lib/estimate-reminders.ts | 33 ++-- packages/api/src/lib/vacation-conflicts.ts | 28 ++-- packages/api/src/router/comment.ts | 32 ++-- packages/api/src/router/notification.ts | 144 ++++++++--------- packages/api/src/router/vacation.ts | 55 +++---- 29 files changed, 737 insertions(+), 607 deletions(-) create mode 100644 apps/web/src/components/ui/EntityCombobox.tsx create mode 100644 packages/api/src/lib/create-notification.ts diff --git a/apps/web/src/components/admin/CalculationRulesClient.tsx b/apps/web/src/components/admin/CalculationRulesClient.tsx index 8811190..a9c4879 100644 --- a/apps/web/src/components/admin/CalculationRulesClient.tsx +++ b/apps/web/src/components/admin/CalculationRulesClient.tsx @@ -1,6 +1,8 @@ "use client"; import { useState } from "react"; +import { AnimatedModal } from "~/components/ui/AnimatedModal.js"; +import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { trpc } from "~/lib/trpc/client.js"; @@ -71,6 +73,7 @@ const emptyRule: EditingRule = { export function CalculationRulesClient() { const [editing, setEditing] = useState(null); + const [confirmDeleteRule, setConfirmDeleteRule] = useState(null); const [error, setError] = useState(null); const utils = trpc.useUtils(); @@ -219,7 +222,7 @@ export function CalculationRulesClient() { - - + + )} + + + {confirmDeleteRule && ( + { + deleteMut.mutate({ id: confirmDeleteRule }); + setConfirmDeleteRule(null); + }} + onCancel={() => setConfirmDeleteRule(null)} + /> )} ); diff --git a/apps/web/src/components/admin/CountriesClient.tsx b/apps/web/src/components/admin/CountriesClient.tsx index dc8f269..d6f5c48 100644 --- a/apps/web/src/components/admin/CountriesClient.tsx +++ b/apps/web/src/components/admin/CountriesClient.tsx @@ -1,6 +1,8 @@ "use client"; import { useState } from "react"; +import { AnimatedModal } from "~/components/ui/AnimatedModal.js"; +import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { trpc } from "~/lib/trpc/client.js"; @@ -58,6 +60,7 @@ export function CountriesClient() { const [editing, setEditing] = useState(null); const [cityName, setCityName] = useState(""); const [expandedId, setExpandedId] = useState(null); + const [confirmDeleteCity, setConfirmDeleteCity] = useState(null); const [error, setError] = useState(null); const utils = trpc.useUtils(); @@ -236,11 +239,7 @@ export function CountriesClient() { {city.name} - - + )} + + + {confirmDeleteCity && ( + { + deleteCityMut.mutate({ id: confirmDeleteCity }); + setConfirmDeleteCity(null); + }} + onCancel={() => setConfirmDeleteCity(null)} + /> )} ); diff --git a/apps/web/src/components/admin/EffortRulesClient.tsx b/apps/web/src/components/admin/EffortRulesClient.tsx index 32517b2..2af1de7 100644 --- a/apps/web/src/components/admin/EffortRulesClient.tsx +++ b/apps/web/src/components/admin/EffortRulesClient.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { trpc } from "~/lib/trpc/client.js"; @@ -87,6 +88,7 @@ export function EffortRulesClient() { const [editing, setEditing] = useState(null); const [expandedId, setExpandedId] = useState(null); + const [confirmDelete, setConfirmDelete] = useState(null); function handleSave() { if (!editing) return; @@ -375,11 +377,7 @@ export function EffortRulesClient() { Edit - - - )} + )} + {/* Level Modal */} - {editingLevel && ( -
-
+ setEditingLevel(null)} maxWidth="max-w-sm"> + {editingLevel && (<>

{editingLevel.id ? "Edit Level" : "Add Level"} @@ -316,8 +312,21 @@ export function ManagementLevelsClient() { {isLevelPending ? "Saving..." : editingLevel.id ? "Update" : "Create"}

-
-
+ )} + + + {confirmDeleteLevel && ( + { + deleteLevelMut.mutate({ id: confirmDeleteLevel }); + setConfirmDeleteLevel(null); + }} + onCancel={() => setConfirmDeleteLevel(null)} + /> )} ); diff --git a/apps/web/src/components/admin/OrgUnitsClient.tsx b/apps/web/src/components/admin/OrgUnitsClient.tsx index a5f9ab0..87c5b99 100644 --- a/apps/web/src/components/admin/OrgUnitsClient.tsx +++ b/apps/web/src/components/admin/OrgUnitsClient.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { AnimatedModal } from "~/components/ui/AnimatedModal.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { trpc } from "~/lib/trpc/client.js"; @@ -195,9 +196,8 @@ export function OrgUnitsClient() { {/* Create/Edit Modal */} - {editing && ( -
-
+ setEditing(null)} maxWidth="max-w-md"> + {editing && (<>

{editing.id ? "Edit Org Unit" : `Add ${LEVEL_LABELS[editing.level] ?? `L${editing.level}`}`} @@ -275,9 +275,8 @@ export function OrgUnitsClient() { {isPending ? "Saving..." : editing.id ? "Update" : "Create"}

-
-
- )} + )} + ); } diff --git a/apps/web/src/components/admin/RateCardsClient.tsx b/apps/web/src/components/admin/RateCardsClient.tsx index 26cd72f..ba4e446 100644 --- a/apps/web/src/components/admin/RateCardsClient.tsx +++ b/apps/web/src/components/admin/RateCardsClient.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js"; import { formatCents } from "~/lib/format.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { trpc } from "~/lib/trpc/client.js"; @@ -101,6 +102,8 @@ export function RateCardsClient() { const [selectedId, setSelectedId] = useState(null); const [editingCard, setEditingCard] = useState(null); const [editingLine, setEditingLine] = useState(null); + const [confirmDeleteLine, setConfirmDeleteLine] = useState(null); + const [confirmDeactivate, setConfirmDeactivate] = useState(null); const [error, setError] = useState(null); const utils = trpc.useUtils(); @@ -260,7 +263,6 @@ export function RateCardsClient() { } async function handleDeleteLine(lineId: string) { - if (!confirm("Delete this rate line?")) return; try { await deleteLineMut.mutateAsync({ lineId }); invalidateAll(); @@ -270,7 +272,6 @@ export function RateCardsClient() { } async function handleDeactivate(id: string) { - if (!confirm("Deactivate this rate card?")) return; try { await deactivateMut.mutateAsync({ id }); invalidateAll(); @@ -445,7 +446,7 @@ export function RateCardsClient() { {detail.isActive ? ( - - - )} + )} + ); } diff --git a/apps/web/src/components/analytics/SkillMarketplace.tsx b/apps/web/src/components/analytics/SkillMarketplace.tsx index cea6537..500be55 100644 --- a/apps/web/src/components/analytics/SkillMarketplace.tsx +++ b/apps/web/src/components/analytics/SkillMarketplace.tsx @@ -3,67 +3,17 @@ import { useState, useMemo } from "react"; import dynamic from "next/dynamic"; import Link from "next/link"; -import { trpc } from "~/lib/trpc/client.js"; -import { useDebounce } from "~/hooks/useDebounce.js"; +import { PROFICIENCY_LABELS, ProficiencyBadge, GapIndicator, formatDate } from "~/components/analytics/skills/shared.js"; import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js"; +import { useDebounce } from "~/hooks/useDebounce.js"; import { useTableSort } from "~/hooks/useTableSort.js"; +import { trpc } from "~/lib/trpc/client.js"; const SkillDistributionChart = dynamic( () => import("~/components/analytics/SkillDistributionChart.js"), { ssr: false, loading: () =>
}, ); -const PROFICIENCY_LABELS = ["", "Aware", "Basic", "Intermediate", "Advanced", "Expert"]; - -const PROFICIENCY_CLASSES = [ - "bg-gray-100 text-gray-700 border-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:border-gray-500", - "bg-blue-100 text-blue-800 border-blue-300 dark:bg-blue-900/60 dark:text-blue-200 dark:border-blue-600", - "bg-indigo-100 text-indigo-800 border-indigo-300 dark:bg-indigo-900/60 dark:text-indigo-200 dark:border-indigo-500", - "bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-900/60 dark:text-amber-200 dark:border-amber-500", - "bg-green-100 text-green-800 border-green-300 dark:bg-green-900/60 dark:text-green-200 dark:border-green-500", -]; - -function proficiencyClasses(level: number): string { - const idx = Math.max(0, Math.min(4, Math.round(level) - 1)); - return PROFICIENCY_CLASSES[idx] ?? PROFICIENCY_CLASSES[0]!; -} - -function ProficiencyBadge({ value }: { value: number }) { - return ( - - {value} {PROFICIENCY_LABELS[value] ?? ""} - - ); -} - -function GapIndicator({ gap }: { gap: number }) { - if (gap > 0) { - return ( - - -{gap} shortage - - ); - } - if (gap < 0) { - return ( - - +{Math.abs(gap)} surplus - - ); - } - return ( - - balanced - - ); -} - -function formatDate(iso: string | null): string { - if (!iso) return "Not within 30d"; - const d = new Date(iso); - return d.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" }); -} - export function SkillMarketplace() { const [searchSkill, setSearchSkill] = useState(""); const [minProficiency, setMinProficiency] = useState(1); diff --git a/apps/web/src/components/analytics/SkillsAnalytics.tsx b/apps/web/src/components/analytics/SkillsAnalytics.tsx index 5e0ef8f..a941156 100644 --- a/apps/web/src/components/analytics/SkillsAnalytics.tsx +++ b/apps/web/src/components/analytics/SkillsAnalytics.tsx @@ -2,6 +2,7 @@ import { useState, useId } from "react"; import dynamic from "next/dynamic"; +import { PROFICIENCY_LABELS, proficiencyClasses, ProficiencyBadge } from "~/components/analytics/skills/shared.js"; import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js"; import { useTableSort } from "~/hooks/useTableSort.js"; import { trpc } from "~/lib/trpc/client.js"; @@ -12,30 +13,6 @@ const SkillDistributionChart = dynamic( { ssr: false, loading: () =>
}, ); -const PROFICIENCY_LABELS = ["", "Aware", "Basic", "Intermediate", "Advanced", "Expert"]; - -// Tailwind class sets per proficiency level (1–5), dark-mode aware -const PROFICIENCY_CLASSES = [ - "bg-gray-100 text-gray-700 border-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:border-gray-500", - "bg-blue-100 text-blue-800 border-blue-300 dark:bg-blue-900/60 dark:text-blue-200 dark:border-blue-600", - "bg-indigo-100 text-indigo-800 border-indigo-300 dark:bg-indigo-900/60 dark:text-indigo-200 dark:border-indigo-500", - "bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-900/60 dark:text-amber-200 dark:border-amber-500", - "bg-green-100 text-green-800 border-green-300 dark:bg-green-900/60 dark:text-green-200 dark:border-green-500", -]; - -function proficiencyClasses(level: number): string { - const idx = Math.max(0, Math.min(4, Math.round(level) - 1)); - return PROFICIENCY_CLASSES[idx] ?? PROFICIENCY_CLASSES[0]!; -} - -function ProficiencyBadge({ value }: { value: number }) { - return ( - - {value} {PROFICIENCY_LABELS[value] ?? ""} - - ); -} - type SkillRule = { skill: string; minProficiency: number }; export function SkillsAnalytics() { diff --git a/apps/web/src/components/comments/CommentThread.tsx b/apps/web/src/components/comments/CommentThread.tsx index 11651bb..06ed9cc 100644 --- a/apps/web/src/components/comments/CommentThread.tsx +++ b/apps/web/src/components/comments/CommentThread.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { clsx } from "clsx"; import { trpc } from "~/lib/trpc/client.js"; +import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js"; import { CommentInput } from "./CommentInput.js"; interface CommentAuthor { @@ -118,6 +119,7 @@ function SingleComment({ isReply?: boolean; }) { const [showReplyInput, setShowReplyInput] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); const utils = trpc.useUtils(); const createMutation = trpc.comment.create.useMutation({ @@ -199,11 +201,7 @@ function SingleComment({ )}
+ {confirmDelete && ( + { + deleteMutation.mutate({ id: comment.id }); + setConfirmDelete(false); + }} + onCancel={() => setConfirmDelete(false)} + /> + )} + {/* Render replies */} {"replies" in comment && comment.replies.length > 0 && (
diff --git a/apps/web/src/components/estimates/ApplyEffortRules.tsx b/apps/web/src/components/estimates/ApplyEffortRules.tsx index 982a4c7..7d1209b 100644 --- a/apps/web/src/components/estimates/ApplyEffortRules.tsx +++ b/apps/web/src/components/estimates/ApplyEffortRules.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { clsx } from "clsx"; +import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js"; import { trpc } from "~/lib/trpc/client.js"; interface ApplyEffortRulesProps { @@ -17,6 +18,7 @@ export function ApplyEffortRules({ estimateId, canEdit, onApplied }: ApplyEffort const [selectedRuleSetId, setSelectedRuleSetId] = useState(""); const [mode, setMode] = useState<"replace" | "append">("replace"); const [showPreview, setShowPreview] = useState(false); + const [confirmApply, setConfirmApply] = useState(false); const previewQuery = trpc.effortRule.preview.useQuery( { estimateId, ruleSetId: selectedRuleSetId }, @@ -106,10 +108,7 @@ export function ApplyEffortRules({ estimateId, canEdit, onApplied }: ApplyEffort
); } diff --git a/apps/web/src/components/estimates/ApplyExperienceMultipliers.tsx b/apps/web/src/components/estimates/ApplyExperienceMultipliers.tsx index db68f99..163f909 100644 --- a/apps/web/src/components/estimates/ApplyExperienceMultipliers.tsx +++ b/apps/web/src/components/estimates/ApplyExperienceMultipliers.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { clsx } from "clsx"; +import { ConfirmDialog } from "~/components/ui/ConfirmDialog.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { formatCents } from "~/lib/format.js"; import { trpc } from "~/lib/trpc/client.js"; @@ -18,6 +19,7 @@ export function ApplyExperienceMultipliers({ estimateId, canEdit, onApplied }: A const [selectedSetId, setSelectedSetId] = useState(""); const [showPreview, setShowPreview] = useState(false); + const [confirmApply, setConfirmApply] = useState(false); const previewQuery = trpc.experienceMultiplier.preview.useQuery( { estimateId, multiplierSetId: selectedSetId }, @@ -96,9 +98,7 @@ export function ApplyExperienceMultipliers({ estimateId, canEdit, onApplied }: A - - + ); } diff --git a/apps/web/src/components/ui/EntityCombobox.tsx b/apps/web/src/components/ui/EntityCombobox.tsx new file mode 100644 index 0000000..cc76a0d --- /dev/null +++ b/apps/web/src/components/ui/EntityCombobox.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { useState, useRef, useEffect, useMemo, type ReactNode } from "react"; +import { useDebounce } from "~/hooks/useDebounce.js"; + +interface EntityComboboxProps { + value: string | null; + onChange: (id: string | null) => void; + placeholder?: string; + disabled?: boolean; + className?: string; + /** Hook that returns search results when the dropdown is open. */ + useSearchQuery: (search: string, enabled: boolean) => { data: T[] | undefined }; + /** Hook that returns a broader list so the selected item's label can be resolved when the dropdown is closed. */ + useSelectedQuery: (id: string | null, enabled: boolean) => { data: T[] | undefined }; + /** Derive the display label from an item (shown in the input when closed). */ + getLabel: (item: T) => string; + /** Optional custom renderer for each dropdown row. Falls back to `getLabel`. */ + renderItem?: (item: T, isSelected: boolean) => ReactNode; +} + +export function EntityCombobox({ + value, + onChange, + placeholder = "Search\u2026", + disabled = false, + className = "", + useSearchQuery, + useSelectedQuery, + getLabel, + renderItem, +}: EntityComboboxProps) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const debouncedSearch = useDebounce(search, 300); + const containerRef = useRef(null); + const inputRef = useRef(null); + + const { data: searchItems } = useSearchQuery(debouncedSearch, open); + const items = searchItems ?? []; + + const { data: selectedItems } = useSelectedQuery(value, !!value && !open); + + const selectedLabel = useMemo(() => { + if (!value) return ""; + const fromOpen = items.find((i) => i.id === value); + if (fromOpen) return getLabel(fromOpen); + const fromSelected = selectedItems?.find((i) => i.id === value); + if (fromSelected) return getLabel(fromSelected); + return value; + }, [value, items, selectedItems, getLabel]); + + useEffect(() => { + if (!open) return; + function handleClick(e: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false); + setSearch(""); + } + } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [open]); + + function handleFocus() { + if (disabled) return; + setOpen(true); + setSearch(""); + } + + function select(id: string | null) { + onChange(id); + setOpen(false); + setSearch(""); + inputRef.current?.blur(); + } + + return ( +
+
+ setSearch(e.target.value)} + onFocus={handleFocus} + placeholder={placeholder} + disabled={disabled} + className={`w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 disabled:opacity-50 disabled:cursor-not-allowed ${ + open + ? "border-brand-500 ring-2 ring-brand-500" + : "border-gray-300 hover:border-gray-400 dark:border-gray-600 dark:hover:border-gray-500" + } bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500`} + readOnly={!open} + /> + {value && !disabled && !open && ( + + )} +
+ + {open && ( +
+
    + {items.length === 0 ? ( +
  • No results
  • + ) : ( + items.map((item) => ( +
  • + +
  • + )) + )} +
+
+ )} +
+ ); +} diff --git a/apps/web/src/components/ui/ProjectCombobox.tsx b/apps/web/src/components/ui/ProjectCombobox.tsx index fe95453..efd956c 100644 --- a/apps/web/src/components/ui/ProjectCombobox.tsx +++ b/apps/web/src/components/ui/ProjectCombobox.tsx @@ -1,9 +1,11 @@ "use client"; -import { useState, useRef, useEffect, useMemo } from "react"; +import { useCallback } from "react"; import { trpc } from "~/lib/trpc/client.js"; -import { useDebounce } from "~/hooks/useDebounce.js"; import type { ProjectStatus } from "@planarchy/shared"; +import { EntityCombobox } from "./EntityCombobox.js"; + +type ProjectItem = { id: string; shortCode: string; name: string }; interface ProjectComboboxProps { value: string | null; @@ -15,122 +17,48 @@ interface ProjectComboboxProps { } export function ProjectCombobox({ - value, - onChange, - placeholder = "Search project\u2026", - disabled = false, status, - className = "", + ...props }: ProjectComboboxProps) { - const [open, setOpen] = useState(false); - const [search, setSearch] = useState(""); - const debouncedSearch = useDebounce(search, 300); - const containerRef = useRef(null); - const inputRef = useRef(null); + const useSearchQuery = (search: string, enabled: boolean) => { + const { data } = trpc.project.list.useQuery( + { search: search || undefined, limit: 15, ...(status ? { status } : {}) }, + { enabled, staleTime: 30_000 }, + ); + return { data: (data?.projects ?? []) as ProjectItem[] }; + }; - const { data } = trpc.project.list.useQuery( - { search: debouncedSearch || undefined, limit: 15, ...(status ? { status } : {}) }, - { enabled: open, staleTime: 30_000 }, + const useSelectedQuery = (_id: string | null, enabled: boolean) => { + const { data } = trpc.project.list.useQuery( + { limit: 500 }, + { enabled, staleTime: 60_000 }, + ); + return { data: (data?.projects ?? []) as ProjectItem[] }; + }; + + const getLabel = useCallback( + (p: ProjectItem) => `${p.shortCode} \u2014 ${p.name}`, + [], ); - const projects = data?.projects ?? []; - - const { data: allData } = trpc.project.list.useQuery( - { limit: 500 }, - { enabled: !!value && !open, staleTime: 60_000 }, + const renderItem = useCallback( + (p: ProjectItem) => ( + <> + {p.shortCode} + {p.name} + + ), + [], ); - const selectedLabel = useMemo(() => { - if (!value) return ""; - const fromOpen = projects.find((p) => p.id === value); - if (fromOpen) return `${fromOpen.shortCode} \u2014 ${fromOpen.name}`; - const fromAll = allData?.projects.find((p) => p.id === value); - if (fromAll) return `${fromAll.shortCode} \u2014 ${fromAll.name}`; - return value; - }, [value, projects, allData]); - - useEffect(() => { - if (!open) return; - function handleClick(e: MouseEvent) { - if (containerRef.current && !containerRef.current.contains(e.target as Node)) { - setOpen(false); - setSearch(""); - } - } - document.addEventListener("mousedown", handleClick); - return () => document.removeEventListener("mousedown", handleClick); - }, [open]); - - function handleFocus() { - if (disabled) return; - setOpen(true); - setSearch(""); - } - - function select(id: string | null) { - onChange(id); - setOpen(false); - setSearch(""); - inputRef.current?.blur(); - } - return ( -
-
- setSearch(e.target.value)} - onFocus={handleFocus} - placeholder={placeholder} - disabled={disabled} - className={`w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 disabled:opacity-50 disabled:cursor-not-allowed ${ - open - ? "border-brand-500 ring-2 ring-brand-500" - : "border-gray-300 hover:border-gray-400 dark:border-gray-600 dark:hover:border-gray-500" - } bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500`} - readOnly={!open} - /> - {value && !disabled && !open && ( - - )} -
- - {open && ( -
-
    - {projects.length === 0 ? ( -
  • No results
  • - ) : ( - projects.map((p) => ( -
  • - -
  • - )) - )} -
-
- )} -
+ + {...props} + placeholder={props.placeholder ?? "Search project\u2026"} + useSearchQuery={useSearchQuery} + useSelectedQuery={useSelectedQuery} + getLabel={getLabel} + renderItem={renderItem} + /> ); } diff --git a/apps/web/src/components/ui/ResourceCombobox.tsx b/apps/web/src/components/ui/ResourceCombobox.tsx index 82c3b29..e8aaaed 100644 --- a/apps/web/src/components/ui/ResourceCombobox.tsx +++ b/apps/web/src/components/ui/ResourceCombobox.tsx @@ -1,8 +1,10 @@ "use client"; -import { useState, useRef, useEffect, useMemo } from "react"; +import { useCallback } from "react"; import { trpc } from "~/lib/trpc/client.js"; -import { useDebounce } from "~/hooks/useDebounce.js"; +import { EntityCombobox } from "./EntityCombobox.js"; + +type ResourceItem = { id: string; displayName: string; eid: string }; interface ResourceComboboxProps { value: string | null; @@ -14,123 +16,48 @@ interface ResourceComboboxProps { } export function ResourceCombobox({ - value, - onChange, - placeholder = "Search resource\u2026", - disabled = false, isActive = true, - className = "", + ...props }: ResourceComboboxProps) { - const [open, setOpen] = useState(false); - const [search, setSearch] = useState(""); - const debouncedSearch = useDebounce(search, 300); - const containerRef = useRef(null); - const inputRef = useRef(null); + const useSearchQuery = (search: string, enabled: boolean) => { + const { data } = trpc.resource.list.useQuery( + { search: search || undefined, limit: 15, isActive }, + { enabled, staleTime: 30_000 }, + ); + return { data: (data?.resources ?? []) as ResourceItem[] }; + }; - const { data } = trpc.resource.list.useQuery( - { search: debouncedSearch || undefined, limit: 15, isActive }, - { enabled: open, staleTime: 30_000 }, + const useSelectedQuery = (_id: string | null, enabled: boolean) => { + const { data } = trpc.resource.list.useQuery( + { limit: 500 }, + { enabled, staleTime: 60_000 }, + ); + return { data: (data?.resources ?? []) as ResourceItem[] }; + }; + + const getLabel = useCallback( + (r: ResourceItem) => `${r.displayName} (${r.eid})`, + [], ); - const resources = (data?.resources ?? []) as Array<{ id: string; displayName: string; eid: string }>; - - const selectedQuery = trpc.resource.list.useQuery( - { limit: 500 }, - { enabled: !!value && !open, staleTime: 60_000 }, + const renderItem = useCallback( + (r: ResourceItem) => ( + <> + {r.displayName} + {r.eid} + + ), + [], ); - const selectedResources = (selectedQuery.data?.resources ?? []) as Array<{ id: string; displayName: string; eid: string }>; - - const selectedLabel = useMemo(() => { - if (!value) return ""; - const fromOpen = resources.find((r) => r.id === value); - if (fromOpen) return `${fromOpen.displayName} (${fromOpen.eid})`; - const fromSelected = selectedResources.find((r) => r.id === value); - if (fromSelected) return `${fromSelected.displayName} (${fromSelected.eid})`; - return value; - }, [value, resources, selectedResources]); - - useEffect(() => { - if (!open) return; - function handleClick(e: MouseEvent) { - if (containerRef.current && !containerRef.current.contains(e.target as Node)) { - setOpen(false); - setSearch(""); - } - } - document.addEventListener("mousedown", handleClick); - return () => document.removeEventListener("mousedown", handleClick); - }, [open]); - - function handleFocus() { - if (disabled) return; - setOpen(true); - setSearch(""); - } - - function select(id: string | null) { - onChange(id); - setOpen(false); - setSearch(""); - inputRef.current?.blur(); - } return ( -
-
- setSearch(e.target.value)} - onFocus={handleFocus} - placeholder={placeholder} - disabled={disabled} - className={`w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 disabled:opacity-50 disabled:cursor-not-allowed ${ - open - ? "border-brand-500 ring-2 ring-brand-500" - : "border-gray-300 hover:border-gray-400 dark:border-gray-600 dark:hover:border-gray-500" - } bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500`} - readOnly={!open} - /> - {value && !disabled && !open && ( - - )} -
- - {open && ( -
-
    - {resources.length === 0 ? ( -
  • No results
  • - ) : ( - resources.map((r) => ( -
  • - -
  • - )) - )} -
-
- )} -
+ + {...props} + placeholder={props.placeholder ?? "Search resource\u2026"} + useSearchQuery={useSearchQuery} + useSelectedQuery={useSelectedQuery} + getLabel={getLabel} + renderItem={renderItem} + /> ); } diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 9289595..d12df5b 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -3,6 +3,7 @@ export { createTRPCContext, createTRPCRouter, createCallerFactory, publicProcedu export { eventBus, emitAllocationCreated, emitAllocationUpdated, emitAllocationDeleted, emitProjectShifted, emitBudgetWarning, flushPendingEvents, cancelPendingEvents } from "./sse/event-bus.js"; export { logger } from "./lib/logger.js"; export { anonymizeResource, anonymizeResources, anonymizeUser, getAnonymizationConfig, getAnonymizationDirectory } from "./lib/anonymization.js"; +export { createNotification, createNotificationsForUsers } from "./lib/create-notification.js"; export { checkBudgetThresholds } from "./lib/budget-alerts.js"; export { checkPendingEstimateReminders } from "./lib/estimate-reminders.js"; export { checkChargeabilityAlerts } from "./lib/chargeability-alerts.js"; diff --git a/packages/api/src/lib/auto-staffing.ts b/packages/api/src/lib/auto-staffing.ts index 501b46f..a10e8b3 100644 --- a/packages/api/src/lib/auto-staffing.ts +++ b/packages/api/src/lib/auto-staffing.ts @@ -1,7 +1,7 @@ import { listAssignmentBookings } from "@planarchy/application"; import { rankResources } from "@planarchy/staffing"; import type { SkillEntry } from "@planarchy/shared"; -import { emitNotificationCreated } from "../sse/event-bus.js"; +import { createNotificationsForUsers } from "./create-notification.js"; /** * Minimal DB interface for auto-staffing — avoids importing the full PrismaClient. @@ -227,24 +227,19 @@ export async function generateAutoSuggestions( select: { id: true }, }); - for (const manager of managers) { - const notification = await db.notification.create({ - data: { - userId: manager.id, - type: "AUTO_STAFFING_SUGGESTION", - category: "NOTIFICATION", - priority: "NORMAL", - title, - body, - entityId: demandRequirementId, - entityType: "demand", - link: `/staffing?demandId=${demandRequirementId}`, - channel: "in_app", - }, - }); - - emitNotificationCreated(manager.id, notification.id); - } + await createNotificationsForUsers({ + db, + userIds: managers.map((m) => m.id), + type: "AUTO_STAFFING_SUGGESTION", + category: "NOTIFICATION", + priority: "NORMAL", + title, + body, + entityId: demandRequirementId, + entityType: "demand", + link: `/staffing?demandId=${demandRequirementId}`, + channel: "in_app", + }); } catch { // Fire-and-forget: swallow all errors to avoid disrupting the caller. } diff --git a/packages/api/src/lib/budget-alerts.ts b/packages/api/src/lib/budget-alerts.ts index 9ab9572..605aa5d 100644 --- a/packages/api/src/lib/budget-alerts.ts +++ b/packages/api/src/lib/budget-alerts.ts @@ -1,5 +1,5 @@ import { listAssignmentBookings } from "@planarchy/application"; -import { emitNotificationCreated } from "../sse/event-bus.js"; +import { createNotificationsForUsers } from "./create-notification.js"; type DbClient = Parameters[0] & { project: { @@ -119,23 +119,18 @@ export async function checkBudgetThresholds( { 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); - } + await createNotificationsForUsers({ + db, + userIds: managers.map((m) => m.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", + }); } } diff --git a/packages/api/src/lib/chargeability-alerts.ts b/packages/api/src/lib/chargeability-alerts.ts index 5ebef5c..b4c5bf2 100644 --- a/packages/api/src/lib/chargeability-alerts.ts +++ b/packages/api/src/lib/chargeability-alerts.ts @@ -8,7 +8,7 @@ import { import type { SpainScheduleRule } from "@planarchy/shared"; import { isChargeabilityActualBooking, listAssignmentBookings } from "@planarchy/application"; import { VacationStatus } from "@planarchy/db"; -import { emitNotificationCreated } from "../sse/event-bus.js"; +import { createNotificationsForUsers } from "./create-notification.js"; /** * Minimal DB client type for chargeability alerts. @@ -237,24 +237,19 @@ export async function checkChargeabilityAlerts( if (existing) continue; - for (const manager of managers) { - const notification = await (db as DbClient).notification.create({ - data: { - userId: manager.id, - type: "CHARGEABILITY_ALERT", - category: "NOTIFICATION", - priority: "HIGH", - title: `Low chargeability: ${resource.displayName}`, - body: `${resource.displayName} is at ${chg}% chargeability this month (target: ${target}%, gap: ${gap}pp).`, - entityId, - entityType: "chargeability_alert", - link: "/chargeability", - channel: "in_app", - }, - }); - - emitNotificationCreated(manager.id, notification.id); - } + await createNotificationsForUsers({ + db: db as DbClient, + userIds: managers.map((m) => m.id), + type: "CHARGEABILITY_ALERT", + category: "NOTIFICATION", + priority: "HIGH", + title: `Low chargeability: ${resource.displayName}`, + body: `${resource.displayName} is at ${chg}% chargeability this month (target: ${target}%, gap: ${gap}pp).`, + entityId, + entityType: "chargeability_alert", + link: "/chargeability", + channel: "in_app", + }); alertCount++; } diff --git a/packages/api/src/lib/create-notification.ts b/packages/api/src/lib/create-notification.ts new file mode 100644 index 0000000..f6c5f99 --- /dev/null +++ b/packages/api/src/lib/create-notification.ts @@ -0,0 +1,102 @@ +import { emitNotificationCreated } from "../sse/event-bus.js"; + +export interface CreateNotificationParams { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + db: { notification: { create: (args: any) => Promise<{ id: string; userId: string }> } }; + userId: string; + type: string; + title: string; + body?: string | undefined; + link?: string | undefined; + entityId?: string | undefined; + entityType?: string | undefined; + category?: string | undefined; + priority?: string | undefined; + senderId?: string | undefined; + channel?: string | undefined; + taskStatus?: string | undefined; + taskAction?: string | undefined; + assigneeId?: string | undefined; + dueDate?: Date | undefined; + sourceId?: string | undefined; + /** Set to false to suppress the SSE emitNotificationCreated call. Default: true. */ + emit?: boolean | undefined; +} + +/** + * Create a single in-app notification and optionally emit an SSE event. + * + * Handles the `exactOptionalPropertyTypes` spread pattern internally so + * callers do not need to repeat the `...(val !== undefined ? { key: val } : {})` boilerplate. + * + * Returns the created notification's ID. + */ +export async function createNotification( + params: CreateNotificationParams, +): Promise { + const { + db, + userId, + type, + title, + body, + link, + entityId, + entityType, + category, + priority, + senderId, + channel, + taskStatus, + taskAction, + assigneeId, + dueDate, + sourceId, + emit = true, + } = params; + + const notification = await db.notification.create({ + data: { + userId, + type, + title, + ...(body !== undefined ? { body } : {}), + ...(link !== undefined ? { link } : {}), + ...(entityId !== undefined ? { entityId } : {}), + ...(entityType !== undefined ? { entityType } : {}), + ...(category !== undefined ? { category } : {}), + ...(priority !== undefined ? { priority } : {}), + ...(senderId !== undefined ? { senderId } : {}), + ...(channel !== undefined ? { channel } : {}), + ...(taskStatus !== undefined ? { taskStatus } : {}), + ...(taskAction !== undefined ? { taskAction } : {}), + ...(assigneeId !== undefined ? { assigneeId } : {}), + ...(dueDate !== undefined ? { dueDate } : {}), + ...(sourceId !== undefined ? { sourceId } : {}), + }, + }); + + if (emit) { + emitNotificationCreated(userId, notification.id); + } + + return notification.id; +} + +/** + * Create one notification per user ID. + * + * Useful for fan-out scenarios (e.g. notifying all managers). + * Returns the count of notifications created. + */ +export async function createNotificationsForUsers( + params: Omit & { userIds: string[] }, +): Promise { + const { userIds, ...rest } = params; + let count = 0; + for (const userId of userIds) { + await createNotification({ ...rest, userId }); + count++; + } + return count; +} diff --git a/packages/api/src/lib/estimate-reminders.ts b/packages/api/src/lib/estimate-reminders.ts index 56cd182..50ef498 100644 --- a/packages/api/src/lib/estimate-reminders.ts +++ b/packages/api/src/lib/estimate-reminders.ts @@ -1,4 +1,4 @@ -import { emitNotificationCreated } from "../sse/event-bus.js"; +import { createNotificationsForUsers } from "./create-notification.js"; type DbClient = { estimate: { @@ -138,24 +138,19 @@ export async function checkPendingEstimateReminders( ) : 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); - } + await createNotificationsForUsers({ + db, + userIds: managers.map((m) => m.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", + }); reminderCount++; } diff --git a/packages/api/src/lib/vacation-conflicts.ts b/packages/api/src/lib/vacation-conflicts.ts index 44e87b8..a20bf79 100644 --- a/packages/api/src/lib/vacation-conflicts.ts +++ b/packages/api/src/lib/vacation-conflicts.ts @@ -1,5 +1,5 @@ import { VacationStatus } from "@planarchy/db"; -import { emitNotificationCreated } from "../sse/event-bus.js"; +import { createNotification } from "./create-notification.js"; type DbClient = { vacation: { @@ -189,21 +189,19 @@ export async function checkVacationConflicts( // Create a notification for the approver if provided if (approverUserId) { - const notification = await db.notification.create({ - data: { - userId: approverUserId, - type: "VACATION_CONFLICT_WARNING", - category: "NOTIFICATION", - priority: "HIGH", - title: `Vacation conflict warning: ${vacation.resource.displayName}`, - body: warning, - entityId: vacationId, - entityType: "vacation", - link: "/vacations", - channel: "in_app", - }, + await createNotification({ + db, + userId: approverUserId, + type: "VACATION_CONFLICT_WARNING", + category: "NOTIFICATION", + priority: "HIGH", + title: `Vacation conflict warning: ${vacation.resource.displayName}`, + body: warning, + entityId: vacationId, + entityType: "vacation", + link: "/vacations", + channel: "in_app", }); - emitNotificationCreated(approverUserId, notification.id); } } diff --git a/packages/api/src/router/comment.ts b/packages/api/src/router/comment.ts index d7246d5..3de7a0f 100644 --- a/packages/api/src/router/comment.ts +++ b/packages/api/src/router/comment.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { TRPCError } from "@trpc/server"; import { SystemRole } from "@planarchy/shared"; import { createTRPCRouter, protectedProcedure } from "../trpc.js"; -import { emitNotificationCreated } from "../sse/event-bus.js"; +import { createNotification } from "../lib/create-notification.js"; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -138,22 +138,20 @@ export const commentRouter = createTRPCRouter({ input.body.length > 120 ? `${input.body.slice(0, 120)}...` : input.body; await Promise.all( - mentionedUserIds.map(async (userId) => { - const notification = await ctx.db.notification.create({ - data: { - userId, - type: "COMMENT_MENTION", - title: `${authorName} mentioned you in a comment`, - body: truncatedBody, - entityId: input.entityId, - entityType: input.entityType, - senderId: authorId, - link: `/estimates/${input.entityId}?tab=comments`, - channel: "in_app", - }, - }); - emitNotificationCreated(userId, notification.id); - }), + mentionedUserIds.map((userId) => + createNotification({ + db: ctx.db, + userId, + type: "COMMENT_MENTION", + title: `${authorName} mentioned you in a comment`, + body: truncatedBody, + entityId: input.entityId, + entityType: input.entityType, + senderId: authorId, + link: `/estimates/${input.entityId}?tab=comments`, + channel: "in_app", + }), + ), ); } diff --git a/packages/api/src/router/notification.ts b/packages/api/src/router/notification.ts index dc8b40e..4088e14 100644 --- a/packages/api/src/router/notification.ts +++ b/packages/api/src/router/notification.ts @@ -9,6 +9,7 @@ import { emitTaskStatusChanged, emitBroadcastSent, } from "../sse/event-bus.js"; +import { createNotification } from "../lib/create-notification.js"; import { resolveRecipients } from "../lib/notification-targeting.js"; import { sendEmail } from "../lib/email.js"; @@ -154,31 +155,28 @@ export const notificationRouter = createTRPCRouter({ .mutation(async ({ ctx, input }) => { const currentUserId = ctx.dbUser.id; - const n = await ctx.db.notification.create({ - data: { - userId: input.userId, - type: input.type, - title: input.title, - ...(input.body !== undefined ? { body: input.body } : {}), - ...(input.entityId !== undefined ? { entityId: input.entityId } : {}), - ...(input.entityType !== undefined ? { entityType: input.entityType } : {}), - ...(input.category !== undefined ? { category: input.category } : {}), - ...(input.priority !== undefined ? { priority: input.priority } : {}), - ...(input.link !== undefined ? { link: input.link } : {}), - ...(input.taskStatus !== undefined ? { taskStatus: input.taskStatus } : {}), - ...(input.taskAction !== undefined ? { taskAction: input.taskAction } : {}), - ...(input.assigneeId !== undefined ? { assigneeId: input.assigneeId } : {}), - ...(input.dueDate !== undefined ? { dueDate: input.dueDate } : {}), - ...(input.channel !== undefined ? { channel: input.channel } : {}), - senderId: input.senderId ?? currentUserId, - }, + const notificationId = await createNotification({ + db: ctx.db, + userId: input.userId, + type: input.type, + title: input.title, + body: input.body, + entityId: input.entityId, + entityType: input.entityType, + category: input.category, + priority: input.priority, + link: input.link, + taskStatus: input.taskStatus, + taskAction: input.taskAction, + assigneeId: input.assigneeId, + dueDate: input.dueDate, + channel: input.channel, + senderId: input.senderId ?? currentUserId, }); - emitNotificationCreated(input.userId, n.id); - // Emit task-specific events if (input.category === "TASK" || input.category === "APPROVAL") { - emitTaskAssigned(input.userId, n.id); + emitTaskAssigned(input.userId, notificationId); } // Email if channel includes email @@ -187,6 +185,8 @@ export const notificationRouter = createTRPCRouter({ void sendNotificationEmail(ctx.db, input.userId, input.title, input.body); } + // Re-fetch for return value (to maintain API contract) + const n = await ctx.db.notification.findUnique({ where: { id: notificationId } }); return n; }), @@ -332,6 +332,9 @@ export const notificationRouter = createTRPCRouter({ .mutation(async ({ ctx, input }) => { const userId = await resolveUserId(ctx); + // Reminders have extra fields (remindAt, nextRemindAt, recurrence) not covered + // by the generic helper, so we keep the direct create here but still use + // the exactOptionalPropertyTypes spread pattern. return ctx.db.notification.create({ data: { userId, @@ -479,54 +482,51 @@ export const notificationRouter = createTRPCRouter({ // 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 } : {}), - }, - }), - ), - ); + // SSE emit handled by createNotification; task events need separate emit + const notificationIds: Array<{ id: string; userId: string }> = []; + for (const recipientUserId of recipientIds) { + const nId = await createNotification({ + db: ctx.db, + userId: recipientUserId, + type: `BROADCAST_${input.category}`, + title: input.title, + body: input.body, + link: input.link, + category: input.category, + priority: input.priority, + channel: input.channel, + sourceId: broadcast.id, + senderId, + taskStatus: isTask ? "OPEN" : undefined, + taskAction: input.taskAction, + dueDate: input.dueDate, + }); + notificationIds.push({ id: nId, userId: recipientUserId }); + if (isTask) { + emitTaskAssigned(recipientUserId, nId); + } + } // 5. Update broadcast with sent info await ctx.db.notificationBroadcast.update({ where: { id: broadcast.id }, data: { sentAt: new Date(), - recipientCount: notifications.length, + recipientCount: notificationIds.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); + // 6. Broadcast-level SSE event + emitBroadcastSent(broadcast.id, notificationIds.length); // 7. Send emails if channel includes email (non-blocking) if (input.channel === "email" || input.channel === "both") { - for (const n of notifications) { + for (const n of notificationIds) { void sendNotificationEmail(ctx.db, n.userId, input.title, input.body); } } - return { ...broadcast, recipientCount: notifications.length, sentAt: new Date() }; + return { ...broadcast, recipientCount: notificationIds.length, sentAt: new Date() }; }), /** List broadcasts */ @@ -565,33 +565,33 @@ export const notificationRouter = createTRPCRouter({ .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 } : {}), - }, + const notificationId = await createNotification({ + db: ctx.db, + userId: input.userId, + type: "TASK_CREATED", + category: "TASK", + taskStatus: "OPEN", + title: input.title, + priority: input.priority, + senderId, + channel: input.channel, + body: input.body, + dueDate: input.dueDate, + taskAction: input.taskAction, + entityId: input.entityId, + entityType: input.entityType, + link: input.link, }); - emitNotificationCreated(input.userId, n.id); - emitTaskAssigned(input.userId, n.id); + emitTaskAssigned(input.userId, notificationId); // Send email if channel includes email if (input.channel === "email" || input.channel === "both") { void sendNotificationEmail(ctx.db, input.userId, input.title, input.body); } + // Re-fetch for return value + const n = await ctx.db.notification.findUnique({ where: { id: notificationId } }); return n; }), diff --git a/packages/api/src/router/vacation.ts b/packages/api/src/router/vacation.ts index a90e4fd..519cc95 100644 --- a/packages/api/src/router/vacation.ts +++ b/packages/api/src/router/vacation.ts @@ -4,7 +4,8 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; import { RESOURCE_BRIEF_SELECT } from "../db/selects.js"; -import { emitVacationCreated, emitVacationUpdated, emitNotificationCreated, emitTaskAssigned } from "../sse/event-bus.js"; +import { emitVacationCreated, emitVacationUpdated, emitTaskAssigned } from "../sse/event-bus.js"; +import { createNotification } from "../lib/create-notification.js"; import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js"; import { sendEmail } from "../lib/email.js"; import { anonymizeResource, anonymizeUser, getAnonymizationDirectory } from "../lib/anonymization.js"; @@ -55,17 +56,15 @@ async function notifyVacationStatus( : `Your vacation request has been ${statusLabel}.`; // In-app notification - const notification = await db.notification.create({ - data: { - userId: resource.user.id, - type: `VACATION_${newStatus}`, - title, - body, - entityId: vacationId, - entityType: "vacation", - }, + await createNotification({ + db, + userId: resource.user.id, + type: `VACATION_${newStatus}`, + title, + body, + entityId: vacationId, + entityType: "vacation", }); - emitNotificationCreated(resource.user.id, notification.id); // Email (non-blocking) if (resource.user.email) { @@ -233,25 +232,23 @@ export const vacationRouter = createTRPCRouter({ 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", - }, + const taskId = await createNotification({ + db: ctx.db, + 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); + emitTaskAssigned(manager.id, taskId); } }