Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
22 changes: 22 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -731,6 +748,7 @@ model Integration {
remediationMappings ExternalRemediationMapping[]
vulnerabilityMappings ExternalVulnerabilityMapping[]
advisoryMappings ExternalAdvisoryMapping[]
workOrderMappings ExternalWorkOrderMapping[]

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
Expand Down Expand Up @@ -821,12 +839,14 @@ enum ResourceType {
Vulnerability
DeviceArtifact
Remediation
WorkOrder
}

enum IntegrationType {
PARTNER
AI
CSAF
REST
}

// Traffic Light Protocol, governs information sharing
Expand Down Expand Up @@ -949,6 +969,7 @@ enum TicketCategory {
FIRMWARE_UPDATE
NETWORK_REMEDIATION
NEW_ASSET_PROCUREMENT
MAINTENANCE
OTHER
}

Expand Down Expand Up @@ -1016,6 +1037,7 @@ model WorkOrderTicket {
watchers TicketWatch[]
seenBy TicketSeen[]
activities TicketActivity[]
externalMappings ExternalWorkOrderMapping[]
lastCommentAt DateTime?

createdAt DateTime @default(now())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import {
BugIcon,
ClipboardListIcon,
ComputerIcon,
CpuIcon,
HeartIcon,
Expand All @@ -27,6 +28,10 @@ const resourceTypeIcons = {
},
[ResourceType.Remediation]: { icon: <HeartIcon />, name: "Remediations" },
[ResourceType.Vulnerability]: { icon: <BugIcon />, name: "Vulnerabilities" },
[ResourceType.WorkOrder]: {
icon: <ClipboardListIcon />,
name: "Work Orders",
},
};

export const ConnectorsLayout = ({
Expand Down
8 changes: 8 additions & 0 deletions src/features/integrations/components/integrations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,14 @@ export const IntegrationCreateModal = ({
badge: "Universal & Adaptive",
icon: <Sparkles size={15} />,
},
{
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"
? [
{
Expand Down
7 changes: 6 additions & 1 deletion src/features/integrations/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,15 @@ export const resourceTypeSchema = z.enum([
"Vulnerability",
"DeviceArtifact",
"Remediation",
"WorkOrder",
]);

export const integrationInputSchema = authSchema
.safeExtend({
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
Expand Down Expand Up @@ -61,6 +62,10 @@ export const integrationsMapping = {
name: "Vulnerability",
type: ResourceType.Vulnerability,
},
workOrders: {
name: "Work Order",
type: ResourceType.WorkOrder,
},
};

export type IntegrationWithRelations = inferOutput<
Expand Down
1 change: 1 addition & 0 deletions src/features/tag-colors/components/tag-colors.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const categoryLabels: Record<TicketCategory, string> = {
FIRMWARE_UPDATE: "Firmware Update",
NETWORK_REMEDIATION: "Network Remediation",
NEW_ASSET_PROCUREMENT: "New Asset Procurement",
MAINTENANCE: "Maintenance",
OTHER: "Other",
};

Expand Down
1 change: 1 addition & 0 deletions src/features/tracking/components/ticket-detail/shared.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export const categoryLabels: Record<TicketCategory, string> = {
FIRMWARE_UPDATE: "Firmware Update",
NETWORK_REMEDIATION: "Network Remediation",
NEW_ASSET_PROCUREMENT: "New Asset Procurement",
MAINTENANCE: "Maintenance",
OTHER: "Other",
};

Expand Down
101 changes: 101 additions & 0 deletions src/features/tracking/server/fleet-mapper.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading