diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index c2b26e45..9f1bd8eb 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -82,7 +82,7 @@ const mainItems = [ url: "/advisories", }, { - title: "Tracking", + title: "Work Orders", icon: ListChecksIcon, url: "/tracking", }, diff --git a/src/features/inbox/agent/classify-kind.ts b/src/features/inbox/agent/classify-kind.ts new file mode 100644 index 00000000..abb5c258 --- /dev/null +++ b/src/features/inbox/agent/classify-kind.ts @@ -0,0 +1,63 @@ +// Routes a relevant inbound email to the right entity: an informational +// Notification, or an actionable Work Order ticket. + +import "server-only"; +import { ChatAnthropic } from "@langchain/anthropic"; +import { HumanMessage } from "@langchain/core/messages"; +import { emailKindSchema } from "../types"; +import { fetchPdfAttachments } from "../utils"; + +const MODEL = "claude-haiku-4-5-20251001"; + +const SYSTEM_PROMPT = `You route inbound emails for a hospital security & operations platform. + +Decide whether an email is a NOTIFICATION or a WORK ORDER: +- "work_order": the email is an ACTIONABLE request directed at the hospital — something a person needs to DO. Examples: a vendor service request or work order, a maintenance/patch/firmware request, a request to schedule or perform work on a device, an RMA, a manual task assignment. +- "notification": the email is INFORMATIONAL — a security advisory, vulnerability disclosure, device recall/safety communication, vendor update announcement, threat bulletin, or other FYI with no specific task asked of the hospital. + +When in doubt between the two, prefer "notification" unless the email clearly asks the hospital to perform an action. + +Always give a concise reasonWhy.`; + +function buildTextPrompt(email: { + from: string; + subject: string | null; + markdown: string; +}): string { + return `From: ${email.from} +Subject: ${email.subject ?? "(no subject)"} + +--- EMAIL BODY --- +${email.markdown}`; +} + +export async function classifyEmailKind( + sourceId: string, + email: { from: string; subject: string | null; markdown: string }, +) { + const pdfAttachments = await fetchPdfAttachments(sourceId); + + const model = new ChatAnthropic({ + model: MODEL, + maxTokens: 256, + }).withStructuredOutput(emailKindSchema); + + const userContent = [ + { type: "text" as const, text: buildTextPrompt(email) }, + ...pdfAttachments.map((pdf) => ({ + type: "document", + source: { + type: "base64", + media_type: "application/pdf", + data: pdf.base64, + }, + title: pdf.filename ?? "attachment.pdf", + })), + ]; + + return model.invoke([ + { role: "system", content: SYSTEM_PROMPT }, + // biome-ignore lint/suspicious/noExplicitAny: cast userContent type for langchain + new HumanMessage({ content: userContent as any }), + ]); +} diff --git a/src/features/inbox/agent/extract-work-order.ts b/src/features/inbox/agent/extract-work-order.ts new file mode 100644 index 00000000..01a2c70f --- /dev/null +++ b/src/features/inbox/agent/extract-work-order.ts @@ -0,0 +1,77 @@ +// Extracts the fields needed to populate a WorkOrderTicket from an actionable +// (work-order) email. The ticket body is taken from the email markdown +// directly; this agent only produces the structured fields around it. + +import "server-only"; +import { ChatAnthropic } from "@langchain/anthropic"; +import { HumanMessage } from "@langchain/core/messages"; +import prisma from "@/lib/db"; +import { type WorkOrderPayload, workOrderPayloadSchema } from "../types"; +import { fetchPdfAttachments } from "../utils"; + +const MODEL = "claude-haiku-4-5-20251001"; + +const SYSTEM_PROMPT = `You extract structured fields for a hospital work-order ticket from an actionable email. + +Produce: +- summary: a concise, action-oriented title (prefer device/vendor specifics over generic phrasing) +- category: the best-fit work category from the allowed set; use OTHER when unsure +- scheduledAt: an ISO 8601 date (YYYY-MM-DD or full timestamp) ONLY if the email states a due/scheduled date; otherwise null +- suggestedAssignee: the responsible party if the email names one (often an external vendor or a person/team); otherwise null +- departmentNames: EVERY hospital department mentioned or implied anywhere in the email — including ones named only in a coordination/aside note (e.g. "have Biomed stage the units"). Choose names ONLY from the "Valid departments" list provided below, copying the spelling exactly; return [] if none apply. + +Only include values you are reasonably confident about. Do not invent dates or assignees, and never invent a department that isn't in the provided list.`; + +function buildTextPrompt(email: { + from: string; + subject: string | null; + markdown: string; +}): string { + return `From: ${email.from} +Subject: ${email.subject ?? "(no subject)"} + +--- EMAIL BODY --- +${email.markdown}`; +} + +export async function extractWorkOrder( + sourceId: string, + email: { from: string; subject: string | null; markdown: string }, +): Promise { + const [pdfAttachments, departments] = await Promise.all([ + fetchPdfAttachments(sourceId), + prisma.department.findMany({ + select: { name: true }, + orderBy: { name: "asc" }, + }), + ]); + const validDepartments = + departments.map((d) => d.name).join(", ") || "(none configured)"; + + const model = new ChatAnthropic({ + model: MODEL, + maxTokens: 1024, + }).withStructuredOutput(workOrderPayloadSchema); + + const userContent = [ + { + type: "text" as const, + text: `${buildTextPrompt(email)}\n\nValid departments (choose only from these): ${validDepartments}`, + }, + ...pdfAttachments.map((pdf) => ({ + type: "document", + source: { + type: "base64", + media_type: "application/pdf", + data: pdf.base64, + }, + title: pdf.filename ?? "attachment.pdf", + })), + ]; + + return model.invoke([ + { role: "system", content: SYSTEM_PROMPT }, + // biome-ignore lint/suspicious/noExplicitAny: cast userContent type for langchain + new HumanMessage({ content: userContent as any }), + ]); +} diff --git a/src/features/inbox/agent/match.test.ts b/src/features/inbox/agent/match.test.ts new file mode 100644 index 00000000..18ee4b63 --- /dev/null +++ b/src/features/inbox/agent/match.test.ts @@ -0,0 +1,133 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); + +const { mockPrisma } = vi.hoisted(() => { + const prisma = { + notificationDeviceGroupMapping: { upsert: vi.fn() }, + deviceGroupMatching: { findUnique: vi.fn(), update: vi.fn() }, + // applyDecisions runs inside prisma.$transaction(async (tx) => …) — invoke + // the callback with the same mock so call assertions still work. + // biome-ignore lint/suspicious/noExplicitAny: callback shape varies + $transaction: vi.fn(async (cb: (tx: any) => Promise) => + cb(prisma), + ), + }; + return { mockPrisma: prisma }; +}); + +vi.mock("@/lib/db", () => ({ default: mockPrisma })); +vi.mock("@/lib/router-utils", () => ({ resolveMatchingId: vi.fn() })); + +import type { Candidates } from "./candidate-search"; +import { applyDecisions, type Decision } from "./match"; + +const candidates: Candidates = { + deviceGroups: [ + { + extracted: { + cpe: null, + manufacturer: "Acme", + modelName: "X1", + version: null, + }, + matches: [ + { + id: "dg-1", + manufacturer: "Acme", + modelName: "X1", + version: null, + versionRange: null, + }, + ], + }, + ], + vulnerabilities: [], + remediations: [], + assets: [], +}; + +const linkDecision: Decision = { + kind: "deviceGroupMatching", + op: "link", + targetId: "dg-1", + confidence: "Matched", + reasonWhy: "same manufacturer + model", + fields: null, +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("applyDecisions — device-group mapping owner", () => { + it("links to a work order via the workOrderTicketId compound key", async () => { + const summary = await applyDecisions( + { workOrderTicketId: "wo-1" }, + [linkDecision], + candidates, + ); + + expect(summary.linked).toBe(1); + expect( + mockPrisma.notificationDeviceGroupMapping.upsert, + ).toHaveBeenCalledTimes(1); + expect( + mockPrisma.notificationDeviceGroupMapping.upsert, + ).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + workOrderTicketId_deviceGroupMatchingId: { + workOrderTicketId: "wo-1", + deviceGroupMatchingId: "dg-1", + }, + }, + create: expect.objectContaining({ + workOrderTicketId: "wo-1", + deviceGroupMatchingId: "dg-1", + confidence: "Matched", + }), + }), + ); + }); + + it("links to a notification via the notificationId compound key", async () => { + const summary = await applyDecisions( + { notificationId: "n-1" }, + [linkDecision], + candidates, + ); + + expect(summary.linked).toBe(1); + expect( + mockPrisma.notificationDeviceGroupMapping.upsert, + ).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + notificationId_deviceGroupMatchingId: { + notificationId: "n-1", + deviceGroupMatchingId: "dg-1", + }, + }, + create: expect.objectContaining({ + notificationId: "n-1", + deviceGroupMatchingId: "dg-1", + }), + }), + ); + }); + + it("skips link decisions whose targetId wasn't a surfaced candidate", async () => { + const summary = await applyDecisions( + { workOrderTicketId: "wo-1" }, + [{ ...linkDecision, targetId: "hallucinated" }], + candidates, + ); + + expect(summary).toMatchObject({ linked: 0, skipped: 1 }); + expect( + mockPrisma.notificationDeviceGroupMapping.upsert, + ).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/inbox/agent/match.ts b/src/features/inbox/agent/match.ts index 125d9093..8c3db291 100644 --- a/src/features/inbox/agent/match.ts +++ b/src/features/inbox/agent/match.ts @@ -66,6 +66,12 @@ const matchSchema = z.object({ export type Decision = z.infer; +// A device-group mapping belongs to exactly one parent — a Notification or a +// WorkOrderTicket. Callers pick which by passing the matching key. +export type MatchOwner = + | { notificationId: string } + | { workOrderTicketId: string }; + export type MatchSummary = { linked: number; updated: number; @@ -253,7 +259,7 @@ function cleanFields(fields: Decision["fields"]): { } export async function matchAndLinkEntities( - notificationId: string, + owner: MatchOwner, extracted: ExtractResult, candidates: Candidates, ): Promise { @@ -271,19 +277,19 @@ export async function matchAndLinkEntities( { role: "user", content: renderCandidates(candidates) }, ]); - return applyDecisions(notificationId, decisions, candidates); + return applyDecisions(owner, decisions, candidates); } /** * Deterministically apply a list of matching decisions to the database. * Separated from the LLM call so it can be unit-tested in isolation. * - * Idempotent: mappings are upserted on (notificationId, deviceGroupId) and new - * device groups are resolved to their canonical (vendor/product/version) - * identity via resolveDeviceGroup, so replaying does not duplicate rows. + * Idempotent: mappings are upserted on (owner, deviceGroupId) and new device + * groups are resolved to their canonical (vendor/product/version) identity via + * resolveDeviceGroup, so replaying does not duplicate rows. */ export async function applyDecisions( - notificationId: string, + owner: MatchOwner, decisions: Decision[], candidates: Candidates, ): Promise { @@ -319,22 +325,41 @@ export async function applyDecisions( decision.confidence === "Matched" ? "Matched" : "NeedsReview"; if (decision.kind === "deviceGroupMatching") { + // A device-group mapping is the only link kind a WorkOrderTicket can + // own; Notifications additionally own vulnerability/remediation/asset + // mappings (handled below). Pick the matching compound key from `owner`. const upsertMapping = (deviceGroupMatchingId: string) => - tx.notificationDeviceGroupMapping.upsert({ - where: { - notificationId_deviceGroupMatchingId: { - notificationId, - deviceGroupMatchingId, - }, - }, - create: { - notificationId, - deviceGroupMatchingId, - confidence, - reasonWhy: decision.reasonWhy, - }, - update: { confidence, reasonWhy: decision.reasonWhy }, - }); + "notificationId" in owner + ? tx.notificationDeviceGroupMapping.upsert({ + where: { + notificationId_deviceGroupMatchingId: { + notificationId: owner.notificationId, + deviceGroupMatchingId, + }, + }, + create: { + notificationId: owner.notificationId, + deviceGroupMatchingId, + confidence, + reasonWhy: decision.reasonWhy, + }, + update: { confidence, reasonWhy: decision.reasonWhy }, + }) + : tx.notificationDeviceGroupMapping.upsert({ + where: { + workOrderTicketId_deviceGroupMatchingId: { + workOrderTicketId: owner.workOrderTicketId, + deviceGroupMatchingId, + }, + }, + create: { + workOrderTicketId: owner.workOrderTicketId, + deviceGroupMatchingId, + confidence, + reasonWhy: decision.reasonWhy, + }, + update: { confidence, reasonWhy: decision.reasonWhy }, + }); const enrichDeviceGroup = async ( deviceGroupMatchingId: string, @@ -434,6 +459,12 @@ export async function applyDecisions( summary.created++; } } else if (decision.kind === "vulnerability") { + // Vulnerability/remediation/asset mappings exist only for + // Notifications — a WorkOrderTicket owns device-group links only. + if (!("notificationId" in owner)) { + summary.skipped++; + continue; + } if ( !decision.targetId || !validIds.vulnerability.has(decision.targetId) @@ -444,12 +475,12 @@ export async function applyDecisions( await tx.notificationVulnerabilityMapping.upsert({ where: { notificationId_vulnerabilityId: { - notificationId, + notificationId: owner.notificationId, vulnerabilityId: decision.targetId, }, }, create: { - notificationId, + notificationId: owner.notificationId, vulnerabilityId: decision.targetId, confidence, reasonWhy: decision.reasonWhy, @@ -473,6 +504,10 @@ export async function applyDecisions( if (decision.op === "update") summary.updated++; else summary.linked++; } else if (decision.kind === "remediation") { + if (!("notificationId" in owner)) { + summary.skipped++; + continue; + } if ( !decision.targetId || !validIds.remediation.has(decision.targetId) @@ -483,12 +518,12 @@ export async function applyDecisions( await tx.notificationRemediationMapping.upsert({ where: { notificationId_remediationId: { - notificationId, + notificationId: owner.notificationId, remediationId: decision.targetId, }, }, create: { - notificationId, + notificationId: owner.notificationId, remediationId: decision.targetId, confidence, reasonWhy: decision.reasonWhy, @@ -508,6 +543,10 @@ export async function applyDecisions( summary.linked++; } } else if (decision.kind === "asset") { + if (!("notificationId" in owner)) { + summary.skipped++; + continue; + } if (!decision.targetId || !validIds.asset.has(decision.targetId)) { summary.skipped++; continue; @@ -515,12 +554,12 @@ export async function applyDecisions( await tx.notificationAssetMapping.upsert({ where: { notificationId_assetId: { - notificationId, + notificationId: owner.notificationId, assetId: decision.targetId, }, }, create: { - notificationId, + notificationId: owner.notificationId, assetId: decision.targetId, confidence, reasonWhy: decision.reasonWhy, diff --git a/src/features/inbox/agent/relevance.ts b/src/features/inbox/agent/relevance.ts index 25208f73..8dd68808 100644 --- a/src/features/inbox/agent/relevance.ts +++ b/src/features/inbox/agent/relevance.ts @@ -11,11 +11,13 @@ const relevanceSchema = z.object({ reason: z.string(), }); -const SYSTEM_PROMPT = `You are a triage agent for a hospital cybersecurity platform. +const SYSTEM_PROMPT = `You are a triage agent for a hospital cybersecurity and operations platform. -Determine whether an incoming email is relevant to hospital cybersecurity or operational security. +Determine whether an incoming email is relevant to hospital cybersecurity, medical-device security, or device/infrastructure operations. -RELEVANT: security advisories, CVE/patch notifications, medical device recalls, FDA/ICS-CERT alerts, threat bulletins, vendor security notices, network incident reports, compliance alerts. +RELEVANT: +- Informational: security advisories, CVE/patch notifications, medical device recalls, FDA/ICS-CERT alerts, threat bulletins, vendor security notices, network incident reports, compliance alerts. +- Actionable: medical-device or infrastructure service/maintenance requests, vendor work orders, requests to patch/update/configure/schedule work on devices, or other tasks asking the hospital to act. NOT RELEVANT: marketing emails, sales pitches, meeting invitations, thank-you notes, general newsletters, HR communications, billing receipts.`; // TODO: Consider also having the model review PDF attachments? diff --git a/src/features/inbox/types.ts b/src/features/inbox/types.ts index 41ede385..f13ba4ba 100644 --- a/src/features/inbox/types.ts +++ b/src/features/inbox/types.ts @@ -1,5 +1,9 @@ import { z } from "zod"; -import type { AssetStatus, Prisma } from "@/generated/prisma"; +import { + type AssetStatus, + type Prisma, + TicketCategory, +} from "@/generated/prisma"; export const notificationInclude = { deviceGroupsMatchings: { @@ -105,3 +109,34 @@ export const notificationPayloadSchema = z.object({ }); export type NotificationPayload = z.infer; + +// Routes a relevant email to the right entity: an informational Notification or +// an actionable Work Order. Kept a flat object (Anthropic tool-schema rule). +export const emailKindSchema = z.object({ + kind: z.enum(["notification", "work_order"]), + reasonWhy: z.string(), +}); + +export type EmailKind = z.infer["kind"]; + +// Fields extracted from an actionable email to populate a WorkOrderTicket. The +// body is taken from the email markdown directly (not from the model). +export const workOrderPayloadSchema = z.object({ + summary: z.string().describe("concise, action-oriented ticket title"), + category: z + .enum(TicketCategory) + .describe("best-fit work category; use OTHER if unsure"), + scheduledAt: z + .string() + .nullable() + .describe("ISO 8601 date if a due/scheduled date is stated, else null"), + suggestedAssignee: z + .string() + .nullable() + .describe("the responsible vendor/person if named, else null"), + departmentNames: z + .array(z.string()) + .describe("hospital department names implied by the email; [] if none"), +}); + +export type WorkOrderPayload = z.infer; diff --git a/src/features/tracking/components/ticket-detail/index.tsx b/src/features/tracking/components/ticket-detail/index.tsx index 98d761ba..a5903280 100644 --- a/src/features/tracking/components/ticket-detail/index.tsx +++ b/src/features/tracking/components/ticket-detail/index.tsx @@ -27,6 +27,7 @@ import { BreadcrumbSeparator, } from "@/components/ui/breadcrumb"; import { Button } from "@/components/ui/button"; +import { MarkdownWithTablesWrapper } from "@/components/ui/markdown-with-tables-wrapper"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { CategoryColorProvider } from "@/features/tag-colors/context"; import { getChipClass } from "@/features/tag-colors/palette"; @@ -74,7 +75,7 @@ export const TicketDetailContent = ({ id }: { id: string }) => { - Tracking + Work Orders {data.parent && ( <> @@ -191,10 +192,14 @@ export const TicketDetailContent = ({ id }: { id: string }) => { - {data.descriptions.length > 0 && ( + {(data.descriptions.length > 0 || data.body) && (
- + {data.descriptions.map((d) => ( @@ -206,6 +211,11 @@ export const TicketDetailContent = ({ id }: { id: string }) => { ))} + {data.body && ( + + Original Email + + )} {data.descriptions.map((d) => ( { value={d.department.id} className="mt-4" > -

{d.body}

+
+ + {d.body} + +
))} + {data.body && ( + +
+ + {data.body} + +
+
+ )}
diff --git a/src/features/tracking/components/tracking.test.tsx b/src/features/tracking/components/tracking.test.tsx index 83fd4465..5d840eba 100644 --- a/src/features/tracking/components/tracking.test.tsx +++ b/src/features/tracking/components/tracking.test.tsx @@ -135,7 +135,7 @@ describe("TrackingHeader", () => { it("renders the title and description", () => { render(); expect( - screen.getByRole("heading", { name: /tracking/i }), + screen.getByRole("heading", { name: /work orders/i }), ).toBeInTheDocument(); expect( screen.getByText(/work order tickets and approval gates/i), @@ -163,7 +163,7 @@ describe("TrackingContainer", () => { , ); expect( - screen.getByRole("heading", { name: /tracking/i }), + screen.getByRole("heading", { name: /work orders/i }), ).toBeInTheDocument(); expect(screen.getByTestId("child")).toHaveTextContent("payload"); }); diff --git a/src/features/tracking/components/tracking.tsx b/src/features/tracking/components/tracking.tsx index 39a8a36e..ec792d30 100644 --- a/src/features/tracking/components/tracking.tsx +++ b/src/features/tracking/components/tracking.tsx @@ -100,7 +100,7 @@ export const TrackingList = () => { export const TrackingHeader = () => { return ( ); diff --git a/src/inngest/functions/process-inbox-email.ts b/src/inngest/functions/process-inbox-email.ts index d218274e..032a04aa 100644 --- a/src/inngest/functions/process-inbox-email.ts +++ b/src/inngest/functions/process-inbox-email.ts @@ -5,7 +5,9 @@ import "server-only"; import TurndownService from "turndown"; import { searchCandidates } from "@/features/inbox/agent/candidate-search"; import { classifyNotification } from "@/features/inbox/agent/classify"; +import { classifyEmailKind } from "@/features/inbox/agent/classify-kind"; import { extractEntities } from "@/features/inbox/agent/extract"; +import { extractWorkOrder } from "@/features/inbox/agent/extract-work-order"; import { matchAndLinkEntities } from "@/features/inbox/agent/match"; import { checkEmailRelevance, @@ -13,12 +15,20 @@ import { } from "@/features/inbox/agent/relevance"; import { triageNotification } from "@/features/inbox/agent/triage"; import { Prisma } from "@/generated/prisma"; +import { getAutomationUser } from "@/lib/automation-user"; import prisma from "@/lib/db"; import { normalizeMd5, uploadBufferToS3 } from "@/lib/s3"; import { inngest } from "../client"; const turndown = new TurndownService(); +// Parse an LLM-provided date string, treating anything unparseable as absent. +function parseScheduledAt(value: string | null): Date | null { + if (!value) return null; + const d = new Date(value); + return Number.isNaN(d.getTime()) ? null : d; +} + export const processInboxEmail = inngest.createFunction( { id: "process-inbox-email" }, { event: "inbox/email.received" }, @@ -175,7 +185,94 @@ export const processInboxEmail = inngest.createFunction( if (!sourceId) return { skipped: true, reason: "duplicate", emailId }; - // 6. Classify email and upsert Notification + // 6. Route the email: informational Notification vs actionable Work Order. + const { kind } = await step.run("classify-kind", () => + classifyEmailKind(sourceId, { + from: email.from, + subject: email.subject, + markdown: markdown ?? "", + }), + ); + + // 6w. Work-order path: extract fields, create the ticket linked to this + // email source, then tag matched device groups onto the ticket. + if (kind === "work_order") { + const wo = await step.run("extract-work-order", () => + extractWorkOrder(sourceId, { + from: email.from, + subject: email.subject, + markdown: markdown ?? "", + }), + ); + + const workOrderTicketId = await step.run( + "create-work-order", + async () => { + const automation = await getAutomationUser(); + + // Match extracted department names to existing departments + // case-insensitively (the table is small; never auto-create). + const wanted = new Set( + wo.departmentNames.map((n) => n.trim().toLowerCase()), + ); + const departmentIds = wanted.size + ? ( + await prisma.department.findMany({ + select: { id: true, name: true }, + }) + ) + .filter((d) => wanted.has(d.name.toLowerCase())) + .map((d) => d.id) + : []; + + const ticket = await prisma.workOrderTicket.create({ + data: { + summary: wo.summary, + body: markdown, + category: wo.category, + scheduledAt: parseScheduledAt(wo.scheduledAt), + suggestedAssignee: wo.suggestedAssignee, + sourceLabel: email.from, + creatorId: automation.id, + departments: { + connect: departmentIds.map((id) => ({ id })), + }, + // Links the email NotificationSource to this ticket (sets its + // workOrderTicketId; notificationId stays null). + sources: { connect: { id: sourceId } }, + }, + }); + return ticket.id; + }, + ); + + const extracted = await step.run("extract-work-order-entities", () => + extractEntities(sourceId, { + from: email.from, + subject: email.subject, + markdown: markdown ?? "", + }), + ); + + const linkSummary = await step.run( + "match-work-order-entities", + async () => { + if (Object.values(extracted).every((v) => v.length === 0)) { + return { linked: 0, updated: 0, created: 0, skipped: 0 }; + } + const candidates = await searchCandidates(extracted); + return matchAndLinkEntities( + { workOrderTicketId }, + extracted, + candidates, + ); + }, + ); + + return { workOrderTicketId, sourceId, emailId, linkSummary }; + } + + // 7. Classify email and upsert Notification const notificationId = await step.run("classify-notification", async () => { const result = await classifyNotification(sourceId, { from: email.from, @@ -216,7 +313,7 @@ export const processInboxEmail = inngest.createFunction( return notification.id; }); - // 7. Extract entities (device groups) referenced in the notification + // 8. Extract entities (device groups) referenced in the notification const extracted = await step.run("extract-entities", () => extractEntities(sourceId, { from: email.from, @@ -225,7 +322,7 @@ export const processInboxEmail = inngest.createFunction( }), ); - // 8. Fuzzy-search the DB for matches and link/update/create them + // 9. Fuzzy-search the DB for matches and link/update/create them const linkSummary = await step.run("match-and-link-entities", async () => { if ( !notificationId || @@ -234,10 +331,10 @@ export const processInboxEmail = inngest.createFunction( return { linked: 0, updated: 0, created: 0, skipped: 0 }; } const candidates = await searchCandidates(extracted); - return matchAndLinkEntities(notificationId, extracted, candidates); + return matchAndLinkEntities({ notificationId }, extracted, candidates); }); - // 9. Triage: assign priority, reason, and hospital impact + // 10. Triage: assign priority, reason, and hospital impact if (notificationId) { await step.run("triage-notification", async () => { const result = await triageNotification(sourceId, notificationId); diff --git a/src/lib/automation-user.ts b/src/lib/automation-user.ts new file mode 100644 index 00000000..777350e7 --- /dev/null +++ b/src/lib/automation-user.ts @@ -0,0 +1,20 @@ +import "server-only"; +import prisma from "@/lib/db"; + +// Fixed id for the single service account that owns automatically-ingested +// records (e.g. work-order tickets created from forwarded emails). Using a +// stable id makes find-or-create idempotent and keeps provenance clear. +export const AUTOMATION_USER_ID = "viper-automation"; + +/** + * Find-or-create the VIPER Automation service user. Used as `creatorId` for + * tickets the system creates on a human's behalf (the human, if any, is + * recorded separately, e.g. in `suggestedAssignee`). + */ +export async function getAutomationUser() { + return prisma.user.upsert({ + where: { id: AUTOMATION_USER_ID }, + create: { id: AUTOMATION_USER_ID, name: "VIPER Automation" }, + update: {}, + }); +}