diff --git a/prisma/migrations/20260708073706_work_order_integration_squashed/migration.sql b/prisma/migrations/20260708073706_work_order_integration_squashed/migration.sql new file mode 100644 index 00000000..1ee4ee3a --- /dev/null +++ b/prisma/migrations/20260708073706_work_order_integration_squashed/migration.sql @@ -0,0 +1,36 @@ +-- AlterEnum +ALTER TYPE "ResourceType" ADD VALUE 'WorkOrder'; + +-- AlterEnum +ALTER TYPE "IntegrationType" ADD VALUE 'REST'; + +-- AlterEnum +ALTER TYPE "TicketCategory" ADD VALUE 'MAINTENANCE'; + +-- CreateTable +CREATE TABLE "external_work_order_mappings" ( + "id" TEXT NOT NULL, + "itemId" TEXT NOT NULL, + "integrationId" TEXT NOT NULL, + "externalId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "lastSynced" TIMESTAMP(3), + + CONSTRAINT "external_work_order_mappings_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "external_work_order_mappings_itemId_idx" ON "external_work_order_mappings"("itemId"); + +-- CreateIndex +CREATE UNIQUE INDEX "external_work_order_mappings_itemId_integrationId_key" ON "external_work_order_mappings"("itemId", "integrationId"); + +-- CreateIndex +CREATE UNIQUE INDEX "external_work_order_mappings_integrationId_externalId_key" ON "external_work_order_mappings"("integrationId", "externalId"); + +-- AddForeignKey +ALTER TABLE "external_work_order_mappings" ADD CONSTRAINT "external_work_order_mappings_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "work_order_ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "external_work_order_mappings" ADD CONSTRAINT "external_work_order_mappings_integrationId_fkey" FOREIGN KEY ("integrationId") REFERENCES "integration"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e9b7c7c3..5df83557 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -421,6 +421,23 @@ model ExternalRemediationMapping { @@map("external_remediation_mappings") } +model ExternalWorkOrderMapping { + id String @id @default(cuid()) + itemId String + integrationId String + externalId String + item WorkOrderTicket @relation(fields: [itemId], references: [id], onDelete: Cascade) + integration Integration @relation(fields: [integrationId], references: [id], onDelete: Cascade) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastSynced DateTime? + + @@unique([itemId, integrationId], name: "external_work_order_mappings_item_integration_key") + @@unique([integrationId, externalId], name: "external_work_order_mappings_integration_external_key") + @@index([itemId]) + @@map("external_work_order_mappings") +} + model ExternalVulnerabilityMapping { id String @id @default(cuid()) itemId String @@ -731,6 +748,7 @@ model Integration { remediationMappings ExternalRemediationMapping[] vulnerabilityMappings ExternalVulnerabilityMapping[] advisoryMappings ExternalAdvisoryMapping[] + workOrderMappings ExternalWorkOrderMapping[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -821,12 +839,14 @@ enum ResourceType { Vulnerability DeviceArtifact Remediation + WorkOrder } enum IntegrationType { PARTNER AI CSAF + REST } // Traffic Light Protocol, governs information sharing @@ -949,6 +969,7 @@ enum TicketCategory { FIRMWARE_UPDATE NETWORK_REMEDIATION NEW_ASSET_PROCUREMENT + MAINTENANCE OTHER } @@ -1016,6 +1037,7 @@ model WorkOrderTicket { watchers TicketWatch[] seenBy TicketSeen[] activities TicketActivity[] + externalMappings ExternalWorkOrderMapping[] lastCommentAt DateTime? createdAt DateTime @default(now()) diff --git a/src/features/api-key-connectors/components/connectors-layout.tsx b/src/features/api-key-connectors/components/connectors-layout.tsx index 7bec9607..e62c8f1d 100644 --- a/src/features/api-key-connectors/components/connectors-layout.tsx +++ b/src/features/api-key-connectors/components/connectors-layout.tsx @@ -2,6 +2,7 @@ import { BugIcon, + ClipboardListIcon, ComputerIcon, CpuIcon, HeartIcon, @@ -27,6 +28,10 @@ const resourceTypeIcons = { }, [ResourceType.Remediation]: { icon: , name: "Remediations" }, [ResourceType.Vulnerability]: { icon: , name: "Vulnerabilities" }, + [ResourceType.WorkOrder]: { + icon: , + name: "Work Orders", + }, }; export const ConnectorsLayout = ({ diff --git a/src/features/integrations/components/integrations.tsx b/src/features/integrations/components/integrations.tsx index e38cda33..7376e5ea 100644 --- a/src/features/integrations/components/integrations.tsx +++ b/src/features/integrations/components/integrations.tsx @@ -159,6 +159,14 @@ export const IntegrationCreateModal = ({ badge: "Universal & Adaptive", icon: , }, + { + value: IntegrationType.REST, + id: "rest", + title: "REST Integration", + description: "Direct pull from a REST API with a built-in adapter", + badge: "e.g., Siemens Fleet", + icon: null, + }, ...(resourceType === "Vulnerability" ? [ { diff --git a/src/features/integrations/types.ts b/src/features/integrations/types.ts index 7df7bdd0..9d898937 100644 --- a/src/features/integrations/types.ts +++ b/src/features/integrations/types.ts @@ -10,6 +10,7 @@ export const resourceTypeSchema = z.enum([ "Vulnerability", "DeviceArtifact", "Remediation", + "WorkOrder", ]); export const integrationInputSchema = authSchema @@ -17,7 +18,7 @@ export const integrationInputSchema = authSchema name: z.string().min(1, "Name is required"), platform: z.string().optional(), integrationUri: safeUrlSchema, - integrationType: z.enum(["PARTNER", "AI", "CSAF"]), + integrationType: z.enum(["PARTNER", "AI", "CSAF", "REST"]), prompt: z.string().optional(), resourceType: resourceTypeSchema, syncEvery: z @@ -61,6 +62,10 @@ export const integrationsMapping = { name: "Vulnerability", type: ResourceType.Vulnerability, }, + workOrders: { + name: "Work Order", + type: ResourceType.WorkOrder, + }, }; export type IntegrationWithRelations = inferOutput< diff --git a/src/features/tag-colors/components/tag-colors.tsx b/src/features/tag-colors/components/tag-colors.tsx index dff2bb61..b4bdf914 100644 --- a/src/features/tag-colors/components/tag-colors.tsx +++ b/src/features/tag-colors/components/tag-colors.tsx @@ -36,6 +36,7 @@ const categoryLabels: Record = { FIRMWARE_UPDATE: "Firmware Update", NETWORK_REMEDIATION: "Network Remediation", NEW_ASSET_PROCUREMENT: "New Asset Procurement", + MAINTENANCE: "Maintenance", OTHER: "Other", }; diff --git a/src/features/tracking/components/ticket-detail/shared.tsx b/src/features/tracking/components/ticket-detail/shared.tsx index 3c8f88c8..d464c28d 100644 --- a/src/features/tracking/components/ticket-detail/shared.tsx +++ b/src/features/tracking/components/ticket-detail/shared.tsx @@ -31,6 +31,7 @@ export const categoryLabels: Record = { FIRMWARE_UPDATE: "Firmware Update", NETWORK_REMEDIATION: "Network Remediation", NEW_ASSET_PROCUREMENT: "New Asset Procurement", + MAINTENANCE: "Maintenance", OTHER: "Other", }; diff --git a/src/features/tracking/server/fleet-mapper.test.ts b/src/features/tracking/server/fleet-mapper.test.ts new file mode 100644 index 00000000..b0a9a9e6 --- /dev/null +++ b/src/features/tracking/server/fleet-mapper.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import { TicketCategory, TicketStatus } from "@/generated/prisma"; +import { deriveOffsetFromUrl, mapFleetActivities } from "./fleet-mapper"; + +// Representative records from a real Fleet /activities response. +const SAMPLE = [ + { + ticketKey: "US_400501937577", + ticketNumber: "400501937577", + equipmentKey: "US_1064669350", + type: "3", + scheduled: false, + plannedStart: null, + plannedEnd: null, + dueDate: "2026-09-11T00:00:00", + completedDate: "2026-02-20T00:00:00", + sapSystem: "P40", + shortText: "Update Service", + qmtext: "UI-MR049/24/P NX VA60A-SP02", + activityTitle: "Update Service: UI-MR049/24/P NX VA60A-SP02", + }, + { + ticketKey: "US_400301843659", + ticketNumber: "400301843659", + equipmentKey: "US_1012141299", + type: "2", + scheduled: true, + plannedStart: "2026-07-22T13:02:00", + plannedEnd: "2026-07-22T17:32:00", + dueDate: "2026-03-22T00:00:00", + completedDate: null, + sapSystem: "P40", + shortText: "Maintenance", + qmtext: "SAFETY RELATED TEST 9Y Annual", + activityTitle: "Maintenance: SAFETY RELATED TEST 9Y Annual", + }, +]; + +describe("deriveOffsetFromUrl", () => { + it("extracts the tz offset from the activities URL", () => { + expect( + deriveOffsetFromUrl( + "https://fleet.siemens-healthineers.com/rest/v1/activities?tz=-05%3A00&statusFilter=1", + ), + ).toBe("-05:00"); + }); + + it("falls back to -05:00 when tz is absent", () => { + expect(deriveOffsetFromUrl("https://example.com/x")).toBe("-05:00"); + expect(deriveOffsetFromUrl(null)).toBe("-05:00"); + }); +}); + +describe("mapFleetActivities", () => { + const items = mapFleetActivities(SAMPLE, { offset: "-05:00" }); + + it("uses ticketKey as the dedup vendorId and activityTitle as summary", () => { + expect(items[0].vendorId).toBe("US_400501937577"); + expect(items[0].summary).toBe( + "Update Service: UI-MR049/24/P NX VA60A-SP02", + ); + }); + + it("maps an unscheduled Update Service to TO_DO / FIRMWARE_UPDATE", () => { + expect(items[0].status).toBe(TicketStatus.TO_DO); + expect(items[0].category).toBe(TicketCategory.FIRMWARE_UPDATE); + }); + + it("maps a scheduled Maintenance to IN_PROGRESS / MAINTENANCE", () => { + expect(items[1].status).toBe(TicketStatus.IN_PROGRESS); + expect(items[1].category).toBe(TicketCategory.MAINTENANCE); + }); + + it("appends the offset to naive datetimes (plannedStart, else dueDate)", () => { + expect(items[0].scheduledAt).toBe("2026-09-11T00:00:00-05:00"); // dueDate + expect(items[1].scheduledAt).toBe("2026-07-22T13:02:00-05:00"); // plannedStart + }); + + it("does not close still-open activities from completedDate", () => { + // Record 0 has a completedDate but is an open activity → must stay TO_DO. + expect(items[0].status).not.toBe(TicketStatus.DONE); + }); + + it("labels the source and includes ticket detail in the body", () => { + expect(items[0].sourceLabel).toBe("Siemens Healthineers Fleet"); + expect(items[0].body).toContain("400501937577"); + expect(items[0].body).toContain("US_1064669350"); + }); + + it("attaches a PolledApi source keyed by ticketKey, preserving the raw record", () => { + expect(items[0].source.channel).toBe("PolledApi"); + expect(items[0].source.externalId).toBe("US_400501937577"); + expect(items[0].source.raw.ticketKey).toBe("US_400501937577"); + // markdown mirrors the ticket body so the source view has detail. + expect(items[0].source.markdown).toBe(items[0].body); + }); + + it("throws on a record missing ticketKey rather than dropping it", () => { + expect(() => mapFleetActivities([{ shortText: "x" }])).toThrow(); + }); +}); diff --git a/src/features/tracking/server/fleet-mapper.ts b/src/features/tracking/server/fleet-mapper.ts new file mode 100644 index 00000000..b3b9eb86 --- /dev/null +++ b/src/features/tracking/server/fleet-mapper.ts @@ -0,0 +1,137 @@ +import { z } from "zod"; +import { + NotificationChannel, + TicketCategory, + TicketStatus, +} from "@/generated/prisma"; + +// Permissive view of a Siemens Healthineers Fleet /activities record. Only the +// fields we consume are declared; unknown fields are stripped. +const fleetActivitySchema = z.object({ + ticketKey: z.string(), + ticketNumber: z.string().nullish(), + equipmentKey: z.string().nullish(), + type: z.string().nullish(), + scheduled: z.boolean().nullish(), + plannedStart: z.string().nullish(), + plannedEnd: z.string().nullish(), + dueDate: z.string().nullish(), + sapSystem: z.string().nullish(), + shortText: z.string().nullish(), + qmtext: z.string().nullish(), + activityTitle: z.string().nullish(), +}); + +export type FleetActivity = z.infer; + +// One item as accepted by POST /workOrders/integrationUpload/{token}. `vendorId` +// is the stable external id used for dedup in external_work_order_mappings. +export interface FleetWorkOrderItem { + vendorId: string; + summary: string; + status: TicketStatus; + category: TicketCategory; + scheduledAt: string | null; + sourceLabel: string; + body: string; + source: { + channel: NotificationChannel; + externalId: string; + referenceUrl: string | null; + markdown: string; + raw: FleetActivity; + }; +} + +const DEFAULT_OFFSET = "-05:00"; + +// The activities URL carries tz=±hh:mm; Fleet's datetimes are naive local values +// in that offset. Fall back to -05:00 when the param is absent. +export function deriveOffsetFromUrl(url: string | null | undefined): string { + const match = decodeURIComponent(url ?? "").match( + /[?&]tz=([+-]\d{2}:?\d{2})/, + ); + return match ? match[1] : DEFAULT_OFFSET; +} + +// Append the offset to a naive datetime; leave already-qualified values as-is. +function toIso(dt: string | null | undefined, offset: string): string | null { + if (!dt) return null; + return /[Zz]$|[+-]\d{2}:?\d{2}$/.test(dt) ? dt : `${dt}${offset}`; +} + +// `scheduled:true` means a service window is booked → treat as in progress. +// Note: Fleet's completedDate is excluded on purpose — it carries the last-done +// date of recurring PMs even for still-open activities, so it must not close a +// ticket. Refine here if Fleet's status-code legend says otherwise. +function mapStatus(a: FleetActivity): TicketStatus { + return a.scheduled === true ? TicketStatus.IN_PROGRESS : TicketStatus.TO_DO; +} + +// "Update Service" (type 3) → software/firmware update. "Maintenance" (type 2), +// including preventive maintenance and safety-related tests, → MAINTENANCE. +function mapCategory(a: FleetActivity): TicketCategory { + const text = (a.shortText ?? "").toLowerCase(); + if (a.type === "3" || text.includes("update")) { + return TicketCategory.FIRMWARE_UPDATE; + } + if (a.type === "2" || text.includes("maintenance")) { + return TicketCategory.MAINTENANCE; + } + return TicketCategory.OTHER; +} + +function buildBody(a: FleetActivity): string { + return [ + `**${a.activityTitle ?? a.shortText ?? "Fleet activity"}**`, + "", + a.ticketNumber ? `- Ticket: ${a.ticketNumber}` : null, + a.equipmentKey ? `- Equipment: ${a.equipmentKey}` : null, + a.qmtext ? `- Description: ${a.qmtext}` : null, + a.dueDate ? `- Due: ${a.dueDate.slice(0, 10)}` : null, + a.plannedStart + ? `- Planned: ${a.plannedStart} → ${a.plannedEnd ?? "?"}` + : null, + a.sapSystem ? `- SAP system: ${a.sapSystem}` : null, + ] + .filter(Boolean) + .join("\n"); +} + +/** + * Map a Siemens Healthineers Fleet /activities payload into Work Order upload + * items. Pure and deterministic. Throws if `raw` isn't the expected array + * shape; individual records missing a ticketKey fail validation loudly rather + * than being silently dropped. + */ +export function mapFleetActivities( + raw: unknown, + opts: { offset?: string } = {}, +): FleetWorkOrderItem[] { + const activities = z.array(fleetActivitySchema).parse(raw); + const offset = opts.offset ?? DEFAULT_OFFSET; + + return activities.map((a) => { + const body = buildBody(a); + return { + vendorId: a.ticketKey, + summary: + a.activityTitle ?? + `${a.shortText ?? "Activity"}: ${a.qmtext ?? a.ticketKey}`, + status: mapStatus(a), + category: mapCategory(a), + scheduledAt: toIso(a.plannedStart ?? a.dueDate, offset), + sourceLabel: "Siemens Healthineers Fleet", + body, + // Polled from a REST API → PolledApi channel. Drives the source badge and + // Suggested-tab membership; `raw` keeps the original activity. + source: { + channel: NotificationChannel.PolledApi, + externalId: a.ticketKey, + referenceUrl: null, + markdown: body, + raw: a, + }, + }; + }); +} diff --git a/src/features/tracking/server/routers.ts b/src/features/tracking/server/routers.ts index 357b299e..bd24808a 100644 --- a/src/features/tracking/server/routers.ts +++ b/src/features/tracking/server/routers.ts @@ -1,18 +1,34 @@ import "server-only"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; -import { type Prisma, TicketCategory, TicketStatus } from "@/generated/prisma"; +import { + type Prisma, + ResourceType, + TicketCategory, + TicketStatus, +} from "@/generated/prisma"; import prisma from "@/lib/db"; import { buildPaginationMeta, createPaginatedResponse, paginationInputSchema, } from "@/lib/pagination"; -import { createSortParser, fetchPaginated } from "@/lib/router-utils"; -import { createTRPCRouter, protectedProcedure } from "@/trpc/init"; +import { + createSortParser, + fetchPaginated, + processIntegrationSync, + processIntegrationToken, +} from "@/lib/router-utils"; +import { integrationResponseSchema } from "@/lib/schemas"; +import { + baseProcedure, + createTRPCRouter, + protectedProcedure, +} from "@/trpc/init"; import { requireExistence } from "@/trpc/middleware"; import { TRACKING_TABS } from "../params"; import { + integrationWorkOrderInputSchema, paginatedWorkOrderListResponseSchema, ticketBaseInclude, ticketCommentResponseSchema, @@ -794,4 +810,73 @@ export const trackingRouter = createTRPCRouter({ return comment; }); }), + + processIntegrationCreate: baseProcedure + .input(integrationWorkOrderInputSchema) + .meta({ + openapi: { + method: "POST", + path: "/workOrders/integrationUpload/{token}", + tags: ["Work Orders"], + summary: "Synchronize Work Orders with integration", + description: + "Synchronize Work Order tickets on VIPER from a partnered platform", + }, + }) + .output(integrationResponseSchema) + .mutation(async ({ input }) => { + const { userId, integrationId } = await processIntegrationToken( + input.token, + ResourceType.WorkOrder, + ); + + return processIntegrationSync( + prisma, + { + model: prisma.workOrderTicket, + mappingModel: prisma.externalWorkOrderMapping, + transformInputItem: async (item, creatorId) => { + const { vendorId, scheduledAt, source, ...fields } = item; + const scheduled = scheduledAt ? new Date(scheduledAt) : null; + return { + // The integration user (resolved from the token) owns tickets it + // creates; WorkOrderTicket requires a creator. + createData: { + ...fields, + scheduledAt: scheduled, + creator: { connect: { id: creatorId } }, + // Attach an ingested-source record (created once with the + // ticket) so it shows a source badge and lands in the Suggested + // tab. Re-syncs take the update path and leave sources alone. + ...(source + ? { + sources: { + create: { + channel: source.channel, + externalId: source.externalId ?? vendorId, + referenceUrl: source.referenceUrl ?? null, + markdown: source.markdown ?? null, + raw: source.raw ?? {}, + }, + }, + } + : {}), + }, + // Never reassign creator on re-sync; only refresh mutable fields. + updateData: { + ...fields, + scheduledAt: scheduled, + }, + // No natural business key for a work order — always create a new + // ticket when there's no existing external mapping. + uniqueFieldConditions: [], + artifactsData: undefined, + }; + }, + }, + input, + userId, + integrationId, + ); + }), }); diff --git a/src/features/tracking/types.ts b/src/features/tracking/types.ts index c29b0de9..0a62bedd 100644 --- a/src/features/tracking/types.ts +++ b/src/features/tracking/types.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { IssueStatus, + NotificationChannel, type Prisma, Severity, TicketActivityType, @@ -8,6 +9,7 @@ import { TicketStatus, } from "@/generated/prisma"; import { createPaginatedResponseSchema } from "@/lib/pagination"; +import { createIntegrationInputSchema } from "@/lib/schemas"; export const ticketBaseInclude = { departments: { @@ -205,6 +207,41 @@ export type WorkOrderListItem = Prisma.WorkOrderTicketGetPayload<{ include: typeof workOrderListInclude; }>; +// --- Integration ingestion ------------------------------------------------- + +// One work-order item as pushed by an integration (e.g. a Fleet activity mapped +// by the AI sync). `summary` is the only required field; everything else falls +// back to model defaults. `scheduledAt` is an ISO-8601 string, coerced to a +// Date at upload time. +export const workOrderIntegrationItemSchema = z.object({ + summary: z.string().min(1, "summary is required"), + status: z.enum(TicketStatus).optional(), + category: z.enum(TicketCategory).optional(), + body: z.string().nullish(), + sourceLabel: z.string().nullish(), + suggestedAssignee: z.string().nullish(), + // Fleet emits naive local datetimes (e.g. "2026-07-22T13:02:00"); the mapper + // should append the request's UTC offset, but we accept both offset and local + // forms so one un-offset value can't reject the entire batch. + scheduledAt: z.string().datetime({ offset: true, local: true }).nullish(), + // Optional ingested-source metadata. When present, a NotificationSource is + // attached to the created ticket so it shows a source badge (by channel) and + // appears in the Suggested (triage) tab. `raw` holds the upstream record. + source: z + .object({ + channel: z.enum(NotificationChannel), + externalId: z.string().nullish(), + referenceUrl: z.string().nullish(), + markdown: z.string().nullish(), + raw: z.any().optional(), + }) + .optional(), +}); + +export const integrationWorkOrderInputSchema = createIntegrationInputSchema( + workOrderIntegrationItemSchema, +); + // --- Input ---------------------------------------------------------------- export const workOrderListFilterSchema = z.object({ diff --git a/src/inngest/functions/sync-integrations.ts b/src/inngest/functions/sync-integrations.ts index 1ae5d62a..e7a71dbe 100644 --- a/src/inngest/functions/sync-integrations.ts +++ b/src/inngest/functions/sync-integrations.ts @@ -5,9 +5,18 @@ import { integrationAssetInputSchema } from "@/features/assets/types"; import { integrationDeviceArtifactInputSchema } from "@/features/device-artifacts/types"; import type { IntegrationWithStringDates } from "@/features/integrations/types"; import { integrationRemediationInputSchema } from "@/features/remediations/types"; +import { + deriveOffsetFromUrl, + mapFleetActivities, +} from "@/features/tracking/server/fleet-mapper"; +import { integrationWorkOrderInputSchema } from "@/features/tracking/types"; import { integrationVulnerabilityInputSchema } from "@/features/vulnerabilities/types"; -import type { ResourceType } from "@/generated/prisma"; -import { AuthType, IntegrationType, SyncStatusEnum } from "@/generated/prisma"; +import { + AuthType, + IntegrationType, + ResourceType, + SyncStatusEnum, +} from "@/generated/prisma"; import prisma from "@/lib/db"; import { createUserToken, DEFAULT_TOKEN_TTL_SECONDS } from "@/lib/tokens"; import { getBaseUrl } from "@/lib/url-utils"; @@ -92,6 +101,11 @@ const getResponseConfig = async ( path: `/vulnerabilities/integrationUpload/${raw}`, schema: z.toJSONSchema(integrationVulnerabilityInputSchema), }; + case "WorkOrder": + return { + path: `/workOrders/integrationUpload/${raw}`, + schema: z.toJSONSchema(integrationWorkOrderInputSchema), + }; default: throw new Error(`Unhandled ResourceType: ${resourceType}`); } @@ -188,6 +202,103 @@ async function syncPartnerIntegration( return { success: true, data }; } +// Registry of built-in REST adapters, keyed by (URL host, resourceType). The +// integration URL is the authoritative identity of the upstream system — +// unlike the free-text name/platform, it can't drift from what's actually being +// called. `hostMatch` is matched case-insensitively as a substring of the URL's +// hostname. A single host can expose multiple endpoints (e.g. Fleet serves +// activities → WorkOrder and security-advisories → Advisory), so resourceType +// disambiguates which adapter to run. +const REST_MAPPERS: Array<{ + hostMatch: string; + resourceType: ResourceType; + map: (raw: unknown, url: string) => unknown[]; +}> = [ + { + hostMatch: "fleet.siemens-healthineers.com", + resourceType: ResourceType.WorkOrder, + map: (raw, url) => + mapFleetActivities(raw, { offset: deriveOffsetFromUrl(url) }), + }, +]; + +function selectRestMapper(integration: IntegrationWithStringDates) { + let host = ""; + try { + host = new URL(integration.integrationUri).hostname.toLowerCase(); + } catch { + // Malformed URL → no adapter matches; the caller throws a clear error. + } + return REST_MAPPERS.find( + (m) => + m.resourceType === integration.resourceType && host.includes(m.hostMatch), + ); +} + +// Helper for REST Integration: the worker pulls the URL itself, maps the +// response with a built-in adapter, then hands the items to the same upload +// endpoint the AI/PARTNER callbacks use (shared dedup + sync-status logic). +async function syncRestIntegration( + integration: IntegrationWithStringDates, +): Promise { + const mapper = selectRestMapper(integration); + if (!mapper) { + throw new Error( + `No REST adapter for integration "${integration.name}" (url: ${integration.integrationUri}, resourceType: ${integration.resourceType})`, + ); + } + + const headers: Record = { Accept: "application/json" }; + if (integration.authType !== AuthType.None) { + const { header, value } = parseAuthenticationJson(integration); + headers[header] = value; + } + + // 1. Pull directly from the REST API. + const upstream = await fetch(integration.integrationUri, { + method: "GET", + headers, + signal: AbortSignal.timeout(30000), + }); + if (!upstream.ok) { + throw new Error( + `Failed to fetch data: ${upstream.status} ${upstream.statusText}`, + ); + } + const rawData = await upstream.json(); + + // 2. Map with the platform adapter. + const items = mapper.map(rawData, integration.integrationUri); + + // 3. Hand off to the token-scoped upload endpoint (token is in the path). + const { path: responsePath } = await getResponseConfig( + integration.integrationUserId, + integration.resourceType, + ); + const uploadResponse = await fetch(`${getBaseUrl()}/api/v1${responsePath}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + signal: AbortSignal.timeout(30000), + body: JSON.stringify({ + items, + page: 1, + pageSize: items.length, + totalCount: items.length, + totalPages: 1, + next: null, + previous: null, + }), + }); + if (!uploadResponse.ok) { + const detail = await uploadResponse.text().catch(() => ""); + throw new Error( + `Upload failed: ${uploadResponse.status} ${uploadResponse.statusText} ${detail}`.trim(), + ); + } + + return { success: true, data: await uploadResponse.json() }; +} + export const syncIntegration = inngest.createFunction( { id: "sync-integration" }, { event: "integration/sync.requested" }, @@ -223,6 +334,8 @@ export const syncIntegration = inngest.createFunction( return await syncAiIntegration(integration); } else if (integration.integrationType === IntegrationType.PARTNER) { return await syncPartnerIntegration(integration); + } else if (integration.integrationType === IntegrationType.REST) { + return await syncRestIntegration(integration); } else if (integration.integrationType === IntegrationType.CSAF) { throw "TODO: VW-227"; } else {