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 {