diff --git a/.gitignore b/.gitignore index 841a2fb..f7516d0 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ npm-debug.log* # Temporary files *.tmp *.temp +tsconfig.tsbuildinfo diff --git a/README.md b/README.md index a443f7a..ef8156d 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Comprehensive, fully-typed Node.js/TypeScript library for the SuperOps.ai GraphQ - **Complete API coverage** - All SuperOps.ai queries and mutations - **Strong TypeScript types** - Full type definitions for all resources - **GraphQL abstraction** - Method-based access without writing raw GraphQL -- **Automatic pagination** - Async iterators for cursor-based pagination +- **Automatic pagination** - Async iterators for page-based pagination - **Rate limit handling** - Built-in request throttling (800 req/min) - **Multi-region support** - US and EU endpoints for MSP and IT verticals - **Zero live API testing** - Full test suite with mocked GraphQL responses @@ -33,14 +33,12 @@ const client = new SuperOpsClient({ // Get an asset const asset = await client.assets.get('asset-123'); -// List tickets with filters +// List tickets with pagination const tickets = await client.tickets.list({ - first: 50, - filter: { - status: ['OPEN'], - priority: 'HIGH', - }, + page: 1, + pageSize: 50, }); +console.log(tickets.items, tickets.meta.totalCount); // Auto-paginate through all clients for await (const clientRecord of client.clients.listAll()) { @@ -112,9 +110,6 @@ const updated = await client.assets.update('asset-123', { }); ``` -> **Note:** Other resources (`tickets`, `clients`, `sites`, …) are still being -> migrated to the real SuperOps schema — see the v2.0.0 tracking issue. Only -> `assets` is verified against SuperOps' published API today. ### Tickets @@ -122,21 +117,12 @@ const updated = await client.assets.update('asset-123', { // Get single ticket const ticket = await client.tickets.get('ticket-001'); -// List tickets with filtering +// List tickets with pagination const result = await client.tickets.list({ - first: 50, - filter: { - status: ['OPEN', 'IN_PROGRESS'], - priority: 'HIGH', - clientId: 'client-456', - }, + page: 1, + pageSize: 50, }); - -// List by status -const openTickets = await client.tickets.listByStatus('OPEN'); - -// List by technician -const techTickets = await client.tickets.listByTechnician('tech-001'); +console.log(result.items, result.meta.totalCount); // Create ticket const newTicket = await client.tickets.create({ @@ -150,23 +136,6 @@ const newTicket = await client.tickets.create({ await client.tickets.update('ticket-001', { priority: 'HIGH', }); - -// Change status -await client.tickets.changeStatus('ticket-001', 'IN_PROGRESS'); - -// Assign ticket -await client.tickets.assign('ticket-001', 'tech-002'); - -// Add note -await client.tickets.addNote('ticket-001', 'Investigating the issue', false); - -// Add time entry -await client.tickets.addTimeEntry('ticket-001', { - startTime: new Date(), - durationMinutes: 30, - description: 'Initial investigation', - billable: true, -}); ``` ### Clients @@ -177,32 +146,21 @@ const clientRecord = await client.clients.get('client-456'); // List clients const result = await client.clients.list({ - filter: { - status: 'ACTIVE', - type: 'CUSTOMER', - }, + page: 1, + pageSize: 50, }); - -// Search clients -const searchResults = await client.clients.search('Acme'); +console.log(result.items, result.meta.totalCount); // Create client const newClient = await client.clients.create({ name: 'New Company', - type: 'CUSTOMER', - primaryContact: { - email: 'contact@company.com', - phone: '555-1234', - }, + stage: 'PROSPECT', }); // Update client await client.clients.update('client-456', { - website: 'https://company.com', + name: 'Updated Company Name', }); - -// Archive client -await client.clients.archive('client-456'); ``` ### Sites @@ -211,27 +169,27 @@ await client.clients.archive('client-456'); // Get site const site = await client.sites.get('site-789'); -// List sites by client -const sites = await client.sites.listByClient('client-456'); +// List sites +const sites = await client.sites.list({ + page: 1, + pageSize: 50, +}); +console.log(sites.items, sites.meta.totalCount); // Create site -const newSite = await client.sites.create('client-456', { +const newSite = await client.sites.create({ name: 'Branch Office', - address: { - street1: '456 Oak Ave', - city: 'Othertown', - state: 'CA', - postalCode: '54321', - }, + line1: '456 Oak Ave', + city: 'Othertown', + stateCode: 'CA', + postalCode: '54321', + clientId: 'client-456', }); // Update site await client.sites.update('site-789', { name: 'Updated Branch Name', }); - -// Delete site -await client.sites.delete('site-789'); ``` ### Alerts @@ -239,50 +197,51 @@ await client.sites.delete('site-789'); ```typescript // List alerts const alerts = await client.alerts.list({ - filter: { - status: 'OPEN', - severity: 'CRITICAL', - }, + page: 1, + pageSize: 50, }); +console.log(alerts.items, alerts.meta.totalCount); -// List by asset -const assetAlerts = await client.alerts.listByAsset('asset-123'); - -// List by client -const clientAlerts = await client.alerts.listByClient('client-456'); - -// List by severity -const criticalAlerts = await client.alerts.listBySeverity('CRITICAL'); +// List alerts for a specific asset +const assetAlerts = await client.alerts.listByAsset('asset-123', { + page: 1, + pageSize: 50, +}); // Create alert const newAlert = await client.alerts.create({ - title: 'Disk Space Low', - message: 'Drive C: is at 95% capacity', + message: 'Disk Space Low', + description: 'Drive C: is at 95% capacity', severity: 'WARNING', assetId: 'asset-123', }); -// Acknowledge alert -await client.alerts.acknowledge('alert-001'); - -// Resolve alert -await client.alerts.resolve('alert-001'); - -// Dismiss alert -await client.alerts.dismiss('alert-001'); +// Resolve alerts +await client.alerts.resolve({ + alertIds: ['alert-001'], + resolutionNote: 'Issue resolved', +}); ``` ### Knowledge Base ```typescript -// Get article -const article = await client.knowledgeBase.getArticle('article-123'); +// Get a KB item (article or collection) +const item = await client.knowledgeBase.get('item-123'); -// Get collection -const collection = await client.knowledgeBase.getCollection('collection-456'); +// List KB items +const items = await client.knowledgeBase.list({ + page: 1, + pageSize: 50, +}); +console.log(items.items, items.meta.totalCount); -// Search knowledge base -const results = await client.knowledgeBase.search('password reset'); +// Create article +const newArticle = await client.knowledgeBase.createArticle({ + name: 'How to Reset Passwords', + description: 'Step-by-step password reset guide', + parentId: 'collection-456', +}); // Create collection const newCollection = await client.knowledgeBase.createCollection({ @@ -290,121 +249,73 @@ const newCollection = await client.knowledgeBase.createCollection({ description: 'Step-by-step guides', }); -// Create article -const newArticle = await client.knowledgeBase.createArticle({ - title: 'How to Reset Passwords', - content: '# Steps\n1. Go to settings...', - collectionId: 'collection-456', - tags: ['passwords', 'self-service'], -}); - // Update article -await client.knowledgeBase.updateArticle('article-123', { - content: '# Updated Steps...', +await client.knowledgeBase.updateArticle({ + itemId: 'article-123', + name: 'Updated Password Reset Guide', + description: 'Updated password reset steps', }); -// Publish article -await client.knowledgeBase.publishArticle('article-123'); -``` - -### Runbooks - -```typescript -// Get runbook -const runbook = await client.runbooks.get('runbook-123'); - -// List runbooks -const runbooks = await client.runbooks.list({ - filter: { - status: 'ACTIVE', - category: 'Maintenance', - }, +// Delete article +await client.knowledgeBase.deleteArticle({ + itemId: 'article-123', }); - -// Execute runbook -const execution = await client.runbooks.execute('runbook-123', [ - 'asset-001', - 'asset-002', -]); - -// Check execution status -const status = await client.runbooks.getExecutionStatus(execution.id); -console.log(status.progress.completed, status.progress.failed); ``` -### Patches +### Contracts ```typescript -// List patches -const patches = await client.patches.list({ - filter: { - severity: 'CRITICAL', - status: 'AVAILABLE', - }, -}); +// Get contract +const contract = await client.contracts.get('contract-123'); -// List by asset -const assetPatches = await client.patches.listByAsset('asset-123'); +// List contracts +const contracts = await client.contracts.list({ + page: 1, + pageSize: 50, +}); +console.log(contracts.items, contracts.meta.totalCount); -// Get compliance report -const report = await client.patches.getComplianceReport({ +// Create contract +const newContract = await client.contracts.create({ clientId: 'client-456', + name: 'Annual Support Contract', + startDate: '2026-01-01', + endDate: '2026-12-31', }); -// Approve patch -await client.patches.approve('patch-001'); - -// Schedule deployment -const deployment = await client.patches.scheduleDeployment({ - name: 'February Security Updates', - scheduledAt: new Date('2026-02-10T02:00:00Z'), - patchIds: ['patch-001', 'patch-002'], - assetIds: ['asset-001', 'asset-002'], - rebootPolicy: 'SCHEDULED', +// Update contract +await client.contracts.update('contract-123', { + name: 'Updated Contract Name', }); ``` -### Remote Sessions - -```typescript -// Initiate remote session -const session = await client.remoteSessions.initiate('asset-123', 'REMOTE_DESKTOP'); - -// Get session info -const sessionInfo = await client.remoteSessions.get(session.id); -console.log(sessionInfo.connectionUrl); - -// Terminate session -await client.remoteSessions.terminate(session.id); -``` - -### Reports +### Technicians ```typescript -// Get ticket metrics -const ticketMetrics = await client.reports.ticketMetrics({ - startDate: '2026-01-01', - endDate: '2026-01-31', +// List technicians +const technicians = await client.technicians.list({ + page: 1, + pageSize: 50, }); - -// Get asset summary -const assetSummary = await client.reports.assetSummary({ - clientId: 'client-456', +console.log(technicians.items, technicians.meta.totalCount); + +// Create technician +const newTechnician = await client.technicians.create({ + firstName: 'John', + lastName: 'Doe', + email: 'john.doe@company.com', + role: 'Technician', }); -// Get technician performance -const performance = await client.reports.technicianPerformance({ - startDate: new Date('2026-01-01'), - endDate: new Date('2026-01-31'), +// Update technician +await client.technicians.update('tech-001', { + phoneNumber: '+1-555-0123', }); - -// Get client health scores -const healthScores = await client.reports.clientHealthScores(); ``` ## Pagination -SuperOps.ai uses cursor-based pagination. The library provides async iterators for automatic pagination: +SuperOps.ai uses page-based pagination. The library provides async iterators for automatic pagination: ```typescript // Auto-paginate all results @@ -415,11 +326,8 @@ for await (const asset of client.assets.listAll()) { // Collect all into an array const allAssets = await client.assets.listAll().toArray(); -// With filters -for await (const ticket of client.tickets.listAll({ - filter: { status: 'OPEN' }, - orderBy: { field: 'CREATED_AT', direction: 'DESC' }, -})) { +// Custom page size for auto-pagination +for await (const ticket of client.tickets.listAll({ pageSize: 100 })) { console.log(ticket.subject); } ``` diff --git a/SCHEMA.md b/SCHEMA.md index 1265a4f..ef52196 100644 --- a/SCHEMA.md +++ b/SCHEMA.md @@ -1,9 +1,8 @@ # SuperOps GraphQL Schema Reference -This document records the SuperOps MSP GraphQL schema as the SDK understands -it. It exists because the SDK was originally written against an **assumed** -schema that did not match the real API (see issue #4) — every resource needs -to be migrated to the real schema, and this file tracks that work. +This document records how the SDK maps onto the SuperOps MSP GraphQL API. It +exists because the SDK was originally written against an **assumed** schema +that did not match the real API (see issue #4). **Source:** SuperOps public API documentation — . @@ -12,51 +11,56 @@ to be migrated to the real schema, and this file tracks that work. - US: `https://api.superops.ai/msp` - EU: `https://euapi.superops.ai/msp` -> ⚠️ This reference was derived from public documentation, not from live schema -> introspection. Object-type field names are reliable; **list input shapes -> (`ListInfoInput`) and `ListInfo` field names are best-effort and should be -> confirmed against the live API.** The authoritative way to verify is a -> GraphQL introspection query against the endpoint above with a valid token. +> ⚠️ **Verification caveat.** This SDK was corrected from public documentation, +> not from live schema introspection (no API tenant was available). Query +> names, the `input:` argument convention, and object-type field names are +> well-corroborated. **Input-type field names — especially `ListInfoInput` and +> create/update inputs — are best-effort and may need adjustment against a +> live tenant.** Such spots are flagged with `// NOTE: unverified against live +> API` comments in the source. The authoritative way to confirm is a GraphQL +> introspection query against the endpoint with a valid token. ## Pagination model SuperOps uses **page-based** pagination, not GraphQL cursor connections: -- List queries take a `ListInfoInput!` argument. +- List queries take a `ListInfoInput!` argument carrying `page` / `pageSize`. - List responses are `List { : [...], listInfo: ListInfo }`. - `ListInfo` reports the page number, page size, and total record count. -The SDK exposes this as `Page { items: T[]; meta: ListMeta }` and a -`PageParams { page?, pageSize? }` input. See `src/types/common.ts`. - -## Assets — migrated ✅ - -```graphql -getAsset(input: AssetIdentifierInput!): Asset -getAssetList(input: ListInfoInput!): AssetList -updateAsset(input: UpdateAssetInput!): Asset - -input AssetIdentifierInput { assetId: String! } - -type Asset { - assetId, name, status - assetClass { classId, name } - client { accountId, name } - site { id, name } - requester { userId, name } - primaryMac, loggedInUser, serialNumber, manufacturer, model - hostName, publicIp, gateway, platform, domain, sysUptime - lastCommunicatedTime, agentVersion - platformFamily, platformCategory, platformVersion, patchStatus - warrantyExpiryDate, purchasedDate, lastReportedTime, customFields -} - -type AssetList { assets: [Asset!]!, listInfo: ListInfo! } -``` - -## Other resources — NOT yet migrated ❌ - -Every other resource (`tickets`, `clients`, `sites`, `alerts`, `contracts`, -`technicians`, `knowledge-base`, …) still uses the original assumed schema and -will fail against the real API. The full query/mutation inventory and the -migration plan are tracked in the v2.0.0 GitHub issue. +The SDK exposes this as `Page { items: T[]; meta: ListMeta }` with a +`PageParams { page?, pageSize? }` input, and an auto-paginating `listAll()` +iterator. See `src/types/common.ts` and `src/pagination.ts`. + +## Migrated resources ✅ + +All operations use SuperOps' `input:` argument convention and page-based lists. +The corrected GraphQL queries live in each `src/resources/*.ts` file, which — +together with the matching `src/types/*.ts` — is the authoritative reference +for what the SDK sends. + +| Resource | SuperOps queries/mutations | +|----------|----------------------------| +| `assets` | `getAsset`, `getAssetList`, `updateAsset` | +| `tickets` | `getTicket`, `getTicketList`, `createTicket`, `updateTicket` | +| `clients` | `getClient`, `getClientList`, `createClientV2`, `updateClient` | +| `sites` | `getClientSite`, `getClientSiteList`, `createClientSite`, `updateClientSite` | +| `alerts` | `getAlertList`, `getAlertsForAsset`, `createAlert`, `resolveAlerts` | +| `contracts` | `getClientContract`, `getClientContractList`, `createClientContract`, `updateClientContract` | +| `technicians` | `getTechnicianList`, `createTechnician`, `updateTechnician`, `deleteTechnician` | +| `knowledgeBase` | `getKbItem`, `getKbItems`, `create/update/deleteKbArticle`, `create/update/deleteKbCollection` | + +## Removed resources + +`runbooks`, `patches`, `remoteSessions`, and `reports` were removed in v2. They +were built entirely on the assumed schema and have no clear standalone +equivalent in the SuperOps MSP API. Patch data (`getAssetPatchDetails`, +`getAssetPatchStatus`) and scripts (`getScriptList`) do exist as asset-scoped +operations and may be reintroduced later if scoped correctly. + +## Test mocks + +`tests/mocks/handlers/` holds one MSW handler file per resource. The mocks +mirror the SDK's queries so unit/integration tests verify the SDK's request +and parsing logic — they do **not** verify the schema against a live API. +That gap is what the verification caveat above refers to. diff --git a/src/client.ts b/src/client.ts index 9cb81e6..7bd7bb4 100644 --- a/src/client.ts +++ b/src/client.ts @@ -16,10 +16,6 @@ import { ContractsResource, TechniciansResource, KnowledgeBaseResource, - RunbooksResource, - PatchesResource, - RemoteSessionsResource, - ReportsResource, } from './resources/index.js'; /** @@ -42,14 +38,8 @@ import { * // Get an asset * const asset = await client.assets.get('asset-123'); * - * // List tickets with filters - * const tickets = await client.tickets.list({ - * first: 50, - * filter: { - * status: ['OPEN'], - * priority: 'HIGH', - * }, - * }); + * // List tickets, one page at a time + * const tickets = await client.tickets.list({ page: 1, pageSize: 50 }); * * // Auto-paginate through all clients * for await (const clientRecord of client.clients.listAll()) { @@ -70,10 +60,6 @@ export class SuperOpsClient { public readonly contracts: ContractsResource; public readonly technicians: TechniciansResource; public readonly knowledgeBase: KnowledgeBaseResource; - public readonly runbooks: RunbooksResource; - public readonly patches: PatchesResource; - public readonly remoteSessions: RemoteSessionsResource; - public readonly reports: ReportsResource; /** * Create a new SuperOps API client @@ -99,10 +85,6 @@ export class SuperOpsClient { this.contracts = new ContractsResource(resourceOptions); this.technicians = new TechniciansResource(resourceOptions); this.knowledgeBase = new KnowledgeBaseResource(resourceOptions); - this.runbooks = new RunbooksResource(resourceOptions); - this.patches = new PatchesResource(resourceOptions); - this.remoteSessions = new RemoteSessionsResource(resourceOptions); - this.reports = new ReportsResource(resourceOptions); } /** diff --git a/src/index.ts b/src/index.ts index 48ed580..b0cbff6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -56,10 +56,6 @@ export { ContractsResource, TechniciansResource, KnowledgeBaseResource, - RunbooksResource, - PatchesResource, - RemoteSessionsResource, - ReportsResource, } from './resources/index.js'; export type { BaseResourceOptions } from './resources/base.js'; diff --git a/src/resources/alerts.ts b/src/resources/alerts.ts index ab5c188..c1790fb 100644 --- a/src/resources/alerts.ts +++ b/src/resources/alerts.ts @@ -1,70 +1,73 @@ /** - * Alerts resource for SuperOps API + * Alerts resource for the SuperOps MSP API. */ -import { BaseResource, prepareFilter, type BaseResourceOptions } from './base.js'; +import { BaseResource, type BaseResourceOptions } from './base.js'; import { gql } from '../graphql-client.js'; import type { Alert, AlertCreateInput, - AlertFilter, - AlertOrderBy, - AlertSeverity, - Connection, - ListParams, + AlertResolveInput, + Page, + PageParams, AsyncIterableWithHelpers, } from '../types/index.js'; /** - * GraphQL fragments for alerts + * GraphQL selection set for an alert. Fields match the SuperOps `Alert` type. */ const ALERT_FRAGMENT = gql` fragment AlertFields on Alert { id - title message - status + description severity - category - source - acknowledgedAt - resolvedAt - dismissedAt - assetId - clientId - siteId - ticketId - createdAt - updatedAt + status + createdTime + resolvedTime asset { - id - name - } - client { - id + assetId name } - site { - id - name - } - ticket { - id - subject - } - acknowledgedBy { - id - name - } - resolvedBy { + policy { id name } } `; +interface GetAlertListResponse { + getAlertList: { + alerts: Alert[]; + listInfo: { + page: number; + pageSize: number; + totalCount: number; + }; + }; +} + +interface GetAlertsForAssetResponse { + getAlertsForAsset: { + alerts: Alert[]; + listInfo: { + page: number; + pageSize: number; + totalCount: number; + }; + }; +} + +interface CreateAlertResponse { + createAlert: Alert; +} + +interface ResolveAlertsResponse { + resolveAlerts: Alert[]; +} + /** - * Alerts resource class + * Alerts resource class. */ export class AlertsResource extends BaseResource { constructor(options: BaseResourceOptions) { @@ -72,266 +75,114 @@ export class AlertsResource extends BaseResource { } /** - * List alerts with pagination and filtering + * List alerts, one page at a time. */ - async list( - params?: ListParams - ): Promise> { + async list(params?: PageParams): Promise> { const query = gql` ${ALERT_FRAGMENT} - query GetAlertList( - $first: Int - $after: String - $filter: AlertFilterInput - $orderBy: AlertOrderInput - ) { - getAlertList(first: $first, after: $after, filter: $filter, orderBy: $orderBy) { - edges { - node { - ...AlertFields - } - cursor + query GetAlertList($input: ListInfoInput!) { + getAlertList(input: $input) { + alerts { + ...AlertFields } - pageInfo { - hasNextPage - hasPreviousPage - startCursor - endCursor + listInfo { + page + pageSize + totalCount } - totalCount } } `; - const variables = { - first: params?.first ?? 50, - after: params?.after, - filter: prepareFilter(params?.filter), - orderBy: params?.orderBy, - }; - - const result = await this.client.query<{ getAlertList: Connection }>(query, variables); - return result.getAlertList; - } + const result = await this.client.query(query, { + input: { + page: params?.page ?? 1, + pageSize: params?.pageSize ?? 50, + }, + }); - /** - * List all alerts with automatic pagination - */ - listAll( - params?: Omit, 'first' | 'after'> - ): AsyncIterableWithHelpers { - return this.createListIterator( - (p) => this.list(p), - params - ); - } - - /** - * List alerts for a specific asset - */ - async listByAsset( - assetId: string, - params?: Omit, 'filter'> - ): Promise> { - const query = gql` - ${ALERT_FRAGMENT} - query GetAlertsForAsset( - $assetId: ID! - $first: Int - $after: String - $orderBy: AlertOrderInput - ) { - getAlertsForAsset(assetId: $assetId, first: $first, after: $after, orderBy: $orderBy) { - edges { - node { - ...AlertFields - } - cursor - } - pageInfo { - hasNextPage - hasPreviousPage - startCursor - endCursor - } - totalCount - } - } - `; - - const variables = { - assetId, - first: params?.first ?? 50, - after: params?.after, - orderBy: params?.orderBy, + return { + items: result.getAlertList.alerts, + meta: result.getAlertList.listInfo, }; - - const result = await this.client.query<{ getAlertsForAsset: Connection }>( - query, - variables - ); - return result.getAlertsForAsset; } /** - * List alerts for a specific client + * Iterate every alert, fetching pages on demand. */ - async listByClient( - clientId: string, - params?: Omit, 'filter'> - ): Promise> { - const query = gql` - ${ALERT_FRAGMENT} - query GetAlertsByClient( - $clientId: ID! - $first: Int - $after: String - $orderBy: AlertOrderInput - ) { - getAlertsByClient(clientId: $clientId, first: $first, after: $after, orderBy: $orderBy) { - edges { - node { - ...AlertFields - } - cursor - } - pageInfo { - hasNextPage - hasPreviousPage - startCursor - endCursor - } - totalCount - } - } - `; - - const variables = { - clientId, - first: params?.first ?? 50, - after: params?.after, - orderBy: params?.orderBy, - }; - - const result = await this.client.query<{ getAlertsByClient: Connection }>( - query, - variables - ); - return result.getAlertsByClient; + listAll(params?: { pageSize?: number }): AsyncIterableWithHelpers { + return this.createPageListIterator((p) => this.list(p), params?.pageSize); } /** - * List alerts by severity + * List alerts for a specific asset. */ - async listBySeverity( - severity: AlertSeverity, - params?: Omit, 'filter'> - ): Promise> { + async listByAsset(assetId: string, params?: PageParams): Promise> { const query = gql` ${ALERT_FRAGMENT} - query GetAlertsBySeverity( - $severity: AlertSeverity! - $first: Int - $after: String - $orderBy: AlertOrderInput - ) { - getAlertsBySeverity(severity: $severity, first: $first, after: $after, orderBy: $orderBy) { - edges { - node { - ...AlertFields - } - cursor + query GetAlertsForAsset($input: AssetDetailsListInput!) { + getAlertsForAsset(input: $input) { + alerts { + ...AlertFields } - pageInfo { - hasNextPage - hasPreviousPage - startCursor - endCursor + listInfo { + page + pageSize + totalCount } - totalCount } } `; - const variables = { - severity, - first: params?.first ?? 50, - after: params?.after, - orderBy: params?.orderBy, + const result = await this.client.query(query, { + input: { + assetId, + page: params?.page ?? 1, + pageSize: params?.pageSize ?? 50, + }, + }); + + return { + items: result.getAlertsForAsset.alerts, + meta: result.getAlertsForAsset.listInfo, }; - - const result = await this.client.query<{ getAlertsBySeverity: Connection }>( - query, - variables - ); - return result.getAlertsBySeverity; } /** - * Create a new alert + * Create a new alert. */ async create(input: AlertCreateInput): Promise { const mutation = gql` ${ALERT_FRAGMENT} - mutation CreateAlert($input: AlertInput!) { + mutation CreateAlert($input: CreateAlertInput!) { createAlert(input: $input) { ...AlertFields } } `; - const result = await this.client.mutate<{ createAlert: Alert }>(mutation, { input }); + const result = await this.client.mutate(mutation, { + input, + }); return result.createAlert; } /** - * Acknowledge an alert - */ - async acknowledge(id: string): Promise { - const mutation = gql` - ${ALERT_FRAGMENT} - mutation AcknowledgeAlert($id: ID!) { - acknowledgeAlert(id: $id) { - ...AlertFields - } - } - `; - - const result = await this.client.mutate<{ acknowledgeAlert: Alert }>(mutation, { id }); - return result.acknowledgeAlert; - } - - /** - * Resolve an alert - */ - async resolve(id: string): Promise { - const mutation = gql` - ${ALERT_FRAGMENT} - mutation ResolveAlert($id: ID!) { - resolveAlert(id: $id) { - ...AlertFields - } - } - `; - - const result = await this.client.mutate<{ resolveAlert: Alert }>(mutation, { id }); - return result.resolveAlert; - } - - /** - * Dismiss an alert + * Resolve one or more alerts. */ - async dismiss(id: string): Promise { + async resolve(input: AlertResolveInput): Promise { const mutation = gql` ${ALERT_FRAGMENT} - mutation DismissAlert($id: ID!) { - dismissAlert(id: $id) { + mutation ResolveAlerts($input: ResolveAlertInput!) { + resolveAlerts(input: $input) { ...AlertFields } } `; - const result = await this.client.mutate<{ dismissAlert: Alert }>(mutation, { id }); - return result.dismissAlert; + const result = await this.client.mutate(mutation, { + input, + }); + return result.resolveAlerts; } } diff --git a/src/resources/clients.ts b/src/resources/clients.ts index bf8ee8b..8c5de1e 100644 --- a/src/resources/clients.ts +++ b/src/resources/clients.ts @@ -1,75 +1,77 @@ /** - * Clients resource for SuperOps API + * Clients resource for the SuperOps MSP API. */ -import { BaseResource, prepareFilter, type BaseResourceOptions } from './base.js'; +import { BaseResource, type BaseResourceOptions } from './base.js'; import { gql } from '../graphql-client.js'; import type { Client, ClientCreateInput, ClientUpdateInput, - ClientFilter, - ClientOrderBy, - Connection, - ListParams, + Page, + PageParams, AsyncIterableWithHelpers, } from '../types/index.js'; /** - * GraphQL fragments for clients + * GraphQL selection set for a client. Fields match the SuperOps `Client` type. */ const CLIENT_FRAGMENT = gql` fragment ClientFields on Client { - id + accountId name + stage status - type - displayName - website - industry - notes - taxId - defaultTechnicianId - createdAt - updatedAt - primaryContact { - email - phone - mobile - fax + emailDomains + accountManager { + userId + name } - billingContact { - email - phone - mobile - fax + primaryContact { + userId + name } - address { - street1 - street2 - city - state - postalCode - country + secondaryContact { + userId + name } - billingAddress { - street1 - street2 - city - state - postalCode - country + hqSite { + id + name } - defaultTechnician { + technicianGroups { id name } - tags + customFields } `; +interface GetClientResponse { + getClient: Client; +} + +interface GetClientListResponse { + getClientList: { + clients: Client[]; + listInfo: { + page: number; + pageSize: number; + totalCount: number; + }; + }; +} + +interface CreateClientResponse { + createClientV2: Client; +} + +interface UpdateClientResponse { + updateClient: Client; +} + /** - * Clients resource class + * Clients resource class. */ export class ClientsResource extends BaseResource { constructor(options: BaseResourceOptions) { @@ -77,172 +79,99 @@ export class ClientsResource extends BaseResource { } /** - * Get a single client by ID + * Get a single client by its account ID. */ - async get(id: string): Promise { + async get(accountId: string): Promise { const query = gql` ${CLIENT_FRAGMENT} - query GetClient($id: ID!) { - getClient(id: $id) { + query GetClient($input: ClientIdentifierInput!) { + getClient(input: $input) { ...ClientFields } } `; - const result = await this.client.query<{ getClient: Client }>(query, { id }); + const result = await this.client.query(query, { + input: { accountId }, + }); return result.getClient; } /** - * List clients with pagination and filtering + * List clients, one page at a time. */ - async list( - params?: ListParams - ): Promise> { + async list(params?: PageParams): Promise> { const query = gql` ${CLIENT_FRAGMENT} - query GetClientList( - $first: Int - $after: String - $filter: ClientFilterInput - $orderBy: ClientOrderInput - ) { - getClientList(first: $first, after: $after, filter: $filter, orderBy: $orderBy) { - edges { - node { - ...ClientFields - } - cursor + query GetClientList($input: ListInfoInput!) { + getClientList(input: $input) { + clients { + ...ClientFields } - pageInfo { - hasNextPage - hasPreviousPage - startCursor - endCursor + listInfo { + page + pageSize + totalCount } - totalCount } } `; - const variables = { - first: params?.first ?? 50, - after: params?.after, - filter: prepareFilter(params?.filter), - orderBy: params?.orderBy, - }; - - const result = await this.client.query<{ getClientList: Connection }>(query, variables); - return result.getClientList; - } + const result = await this.client.query(query, { + input: { + page: params?.page ?? 1, + pageSize: params?.pageSize ?? 50, + }, + }); - /** - * List all clients with automatic pagination - */ - listAll( - params?: Omit, 'first' | 'after'> - ): AsyncIterableWithHelpers { - return this.createListIterator( - (p) => this.list(p), - params - ); + return { + items: result.getClientList.clients, + meta: result.getClientList.listInfo, + }; } /** - * Search clients by query string + * Iterate every client, fetching pages on demand. */ - async search( - query: string, - params?: Omit, 'filter'> - ): Promise> { - const gqlQuery = gql` - ${CLIENT_FRAGMENT} - query SearchClients( - $query: String! - $first: Int - $after: String - $orderBy: ClientOrderInput - ) { - searchClients(query: $query, first: $first, after: $after, orderBy: $orderBy) { - edges { - node { - ...ClientFields - } - cursor - } - pageInfo { - hasNextPage - hasPreviousPage - startCursor - endCursor - } - totalCount - } - } - `; - - const variables = { - query, - first: params?.first ?? 50, - after: params?.after, - orderBy: params?.orderBy, - }; - - const result = await this.client.query<{ searchClients: Connection }>( - gqlQuery, - variables - ); - return result.searchClients; + listAll(params?: { pageSize?: number }): AsyncIterableWithHelpers { + return this.createPageListIterator((p) => this.list(p), params?.pageSize); } /** - * Create a new client + * Create a new client. */ async create(input: ClientCreateInput): Promise { const mutation = gql` ${CLIENT_FRAGMENT} - mutation CreateClient($input: ClientInput!) { - createClient(input: $input) { + mutation CreateClientV2($input: CreateClientInputV2!) { + createClientV2(input: $input) { ...ClientFields } } `; - const result = await this.client.mutate<{ createClient: Client }>(mutation, { input }); - return result.createClient; + const result = await this.client.mutate(mutation, { + input, + }); + return result.createClientV2; } /** - * Update an existing client + * Update an existing client. */ - async update(id: string, input: ClientUpdateInput): Promise { + async update(accountId: string, input: ClientUpdateInput): Promise { const mutation = gql` ${CLIENT_FRAGMENT} - mutation UpdateClient($id: ID!, $input: ClientInput!) { - updateClient(id: $id, input: $input) { + mutation UpdateClient($input: UpdateClientInput!) { + updateClient(input: $input) { ...ClientFields } } `; - const result = await this.client.mutate<{ updateClient: Client }>(mutation, { id, input }); + const result = await this.client.mutate(mutation, { + input: { accountId, ...input }, + }); return result.updateClient; } - - /** - * Archive a client - */ - async archive(id: string): Promise { - const mutation = gql` - ${CLIENT_FRAGMENT} - mutation ArchiveClient($id: ID!) { - archiveClient(id: $id) { - ...ClientFields - } - } - `; - - const result = await this.client.mutate<{ archiveClient: Client }>(mutation, { id }); - return result.archiveClient; - } } diff --git a/src/resources/contracts.ts b/src/resources/contracts.ts index 44b0a27..45879b4 100644 --- a/src/resources/contracts.ts +++ b/src/resources/contracts.ts @@ -1,5 +1,5 @@ /** - * Contracts resource for SuperOps API + * Contracts resource for the SuperOps MSP API. */ import { BaseResource, type BaseResourceOptions } from './base.js'; @@ -8,42 +8,64 @@ import type { Contract, ContractCreateInput, ContractUpdateInput, - ContractRenewalInput, - ContractFilter, - ContractOrderBy, - Connection, - ListParams, + Page, + PageParams, AsyncIterableWithHelpers, } from '../types/index.js'; /** - * GraphQL fragments for contracts + * GraphQL selection set for a client contract. Fields match the SuperOps `ClientContract` type. */ const CONTRACT_FRAGMENT = gql` - fragment ContractFields on Contract { - id - name - status - clientId - startDate - endDate - billingCycle - value - currency - description - autoRenew - renewalNotificationDays - createdAt - updatedAt + fragment ContractFields on ClientContract { + contractId client { - id + accountId name } + contract { + name + description + startDate + endDate + contractStatus + contractValue + currency + billingCycle + autoRenew + customFields + } + startDate + endDate + contractStatus } `; +interface GetClientContractResponse { + getClientContract: Contract; +} + +interface GetClientContractListResponse { + getClientContractList: { + clientContracts: Contract[]; + listInfo: { + page: number; + pageSize: number; + totalCount: number; + }; + }; +} + +interface CreateClientContractResponse { + createClientContract: string; // Returns ID +} + +interface UpdateClientContractResponse { + updateClientContract: Contract; +} + /** - * Contracts resource class + * Contracts resource class. */ export class ContractsResource extends BaseResource { constructor(options: BaseResourceOptions) { @@ -51,133 +73,96 @@ export class ContractsResource extends BaseResource { } /** - * Get a single contract by ID + * Get a single client contract by its contract ID. */ - async get(id: string): Promise { + async get(contractId: number): Promise { const query = gql` ${CONTRACT_FRAGMENT} - query GetContract($id: ID!) { - getContract(id: $id) { + query GetClientContract($input: ContractIdentifierInput!) { + getClientContract(input: $input) { ...ContractFields } } `; - const result = await this.client.query<{ getContract: Contract }>(query, { id }); - return result.getContract; + const result = await this.client.query(query, { + input: { contractId }, + }); + return result.getClientContract; } /** - * List contracts by client ID + * List client contracts, one page at a time. */ - async listByClient( - clientId: string, - params?: Omit, 'filter'> - ): Promise> { + async list(params?: PageParams): Promise> { const query = gql` ${CONTRACT_FRAGMENT} - query GetContractsByClient( - $clientId: ID! - $first: Int - $after: String - $orderBy: ContractOrderInput - ) { - getContractsByClient(clientId: $clientId, first: $first, after: $after, orderBy: $orderBy) { - edges { - node { - ...ContractFields - } - cursor + query GetClientContractList($input: ListInfoInput) { + getClientContractList(input: $input) { + clientContracts { + ...ContractFields } - pageInfo { - hasNextPage - hasPreviousPage - startCursor - endCursor + listInfo { + page + pageSize + totalCount } - totalCount } } `; - const variables = { - clientId, - first: params?.first ?? 50, - after: params?.after, - orderBy: params?.orderBy, - }; + const result = await this.client.query(query, { + input: { + page: params?.page ?? 1, + pageSize: params?.pageSize ?? 50, + }, + }); - const result = await this.client.query<{ getContractsByClient: Connection }>( - query, - variables - ); - return result.getContractsByClient; + return { + items: result.getClientContractList.clientContracts, + meta: result.getClientContractList.listInfo, + }; } /** - * List all contracts by client with automatic pagination + * Iterate every client contract, fetching pages on demand. */ - listByClientAll( - clientId: string, - params?: Omit, 'first' | 'after' | 'filter'> - ): AsyncIterableWithHelpers { - return this.createListIterator( - (p) => this.listByClient(clientId, { first: p.first, after: p.after, orderBy: p.orderBy }), - params - ); + listAll(params?: { pageSize?: number }): AsyncIterableWithHelpers { + return this.createPageListIterator((p) => this.list(p), params?.pageSize); } /** - * Create a new contract for a client + * Create a new client contract. */ - async create(clientId: string, input: ContractCreateInput): Promise { + async create(input: ContractCreateInput): Promise { const mutation = gql` - ${CONTRACT_FRAGMENT} - mutation CreateClientContract($clientId: ID!, $input: ContractInput!) { - createClientContract(clientId: $clientId, input: $input) { - ...ContractFields - } + mutation CreateClientContract($input: CreateClientContractInput!) { + createClientContract(input: $input) } `; - const result = await this.client.mutate<{ createClientContract: Contract }>(mutation, { - clientId, + const result = await this.client.mutate(mutation, { input, }); return result.createClientContract; } /** - * Update an existing contract + * Update an existing client contract. */ - async update(id: string, input: ContractUpdateInput): Promise { + async update(contractId: number, input: ContractUpdateInput): Promise { const mutation = gql` ${CONTRACT_FRAGMENT} - mutation UpdateContract($id: ID!, $input: ContractInput!) { - updateContract(id: $id, input: $input) { + mutation UpdateClientContract($input: UpdateClientContractInput!) { + updateClientContract(input: $input) { ...ContractFields } } `; - const result = await this.client.mutate<{ updateContract: Contract }>(mutation, { id, input }); - return result.updateContract; - } - - /** - * Renew a contract - */ - async renew(id: string, input: ContractRenewalInput): Promise { - const mutation = gql` - ${CONTRACT_FRAGMENT} - mutation RenewContract($id: ID!, $input: RenewalInput!) { - renewContract(id: $id, input: $input) { - ...ContractFields - } - } - `; - - const result = await this.client.mutate<{ renewContract: Contract }>(mutation, { id, input }); - return result.renewContract; + const result = await this.client.mutate(mutation, { + input: { contractId, ...input }, + }); + return result.updateClientContract; } -} +} \ No newline at end of file diff --git a/src/resources/index.ts b/src/resources/index.ts index 10ceab7..61acd20 100644 --- a/src/resources/index.ts +++ b/src/resources/index.ts @@ -11,7 +11,3 @@ export { AlertsResource } from './alerts.js'; export { ContractsResource } from './contracts.js'; export { TechniciansResource } from './technicians.js'; export { KnowledgeBaseResource } from './knowledge-base.js'; -export { RunbooksResource } from './runbooks.js'; -export { PatchesResource } from './patches.js'; -export { RemoteSessionsResource } from './remote-sessions.js'; -export { ReportsResource } from './reports.js'; diff --git a/src/resources/knowledge-base.ts b/src/resources/knowledge-base.ts index 4733a49..9b8a55d 100644 --- a/src/resources/knowledge-base.ts +++ b/src/resources/knowledge-base.ts @@ -1,82 +1,98 @@ /** - * Knowledge Base resource for SuperOps API + * Knowledge Base resource for the SuperOps MSP API. */ -import { BaseResource, prepareFilter, type BaseResourceOptions } from './base.js'; +import { BaseResource, type BaseResourceOptions } from './base.js'; import { gql } from '../graphql-client.js'; import type { - KbArticle, - KbCollection, - KbArticleCreateInput, - KbArticleUpdateInput, - KbCollectionCreateInput, - KbCollectionUpdateInput, - KbArticleFilter, - KbArticleOrderBy, - KbSearchResult, - Connection, - ListParams, + KbItem, + KbCreateArticleInput, + KbUpdateArticleInput, + KbDeleteArticleInput, + KbCreateCollectionInput, + KbUpdateCollectionInput, + KbDeleteCollectionInput, + Page, + PageParams, AsyncIterableWithHelpers, } from '../types/index.js'; /** - * GraphQL fragments for knowledge base + * GraphQL selection set for a KB item. Fields match the SuperOps `KbItem` type. + * NOTE: Some field names are unverified against live API */ -const KB_ARTICLE_FRAGMENT = gql` - fragment KbArticleFields on KbArticle { - id - title - content - status - visibility - slug - excerpt - collectionId - publishedAt - viewCount - helpfulCount - notHelpfulCount - tags - relatedArticleIds - createdAt - updatedAt - collection { - id +const KB_ITEM_FRAGMENT = gql` + fragment KbItemFields on KbItem { + itemId + name + parent { + itemId name } - author { - id + itemType + description + status + createdBy { + userId name } - attachments { - id + createdOn + lastModifiedBy { + userId name - url - size - mimeType } - } -`; - -const KB_COLLECTION_FRAGMENT = gql` - fragment KbCollectionFields on KbCollection { - id - name - description - slug - parentId - articleCount - createdAt - updatedAt - parent { - id - name + lastModifiedOn + viewCount + articleType + visibility { + shared + sharedWith } + loginRequired } `; +interface GetKbItemResponse { + getKbItem: KbItem; +} + +interface GetKbItemsResponse { + getKbItems: { + kbItems: KbItem[]; + listInfo: { + page: number; + pageSize: number; + totalCount: number; + }; + }; +} + +interface CreateKbArticleResponse { + createKbArticle: KbItem; +} + +interface UpdateKbArticleResponse { + updateKbArticle: KbItem; +} + +interface DeleteKbArticleResponse { + deleteKbArticle: boolean; +} + +interface CreateKbCollectionResponse { + createKbCollection: KbItem; +} + +interface UpdateKbCollectionResponse { + updateKbCollection: KbItem; +} + +interface DeleteKbCollectionResponse { + deleteKbCollection: boolean; +} + /** - * Knowledge Base resource class + * Knowledge Base resource class. */ export class KnowledgeBaseResource extends BaseResource { constructor(options: BaseResourceOptions) { @@ -84,247 +100,169 @@ export class KnowledgeBaseResource extends BaseResource { } /** - * Get a single article by ID + * Get a single KB item (article or collection) by its item ID. */ - async getArticle(id: string): Promise { + async get(itemId: string): Promise { const query = gql` - ${KB_ARTICLE_FRAGMENT} - query GetKbArticle($id: ID!) { - getKbArticle(id: $id) { - ...KbArticleFields + ${KB_ITEM_FRAGMENT} + query GetKbItem($input: KBItemIdentifierInput!) { + getKbItem(input: $input) { + ...KbItemFields } } `; - const result = await this.client.query<{ getKbArticle: KbArticle }>(query, { id }); - return result.getKbArticle; + const result = await this.client.query(query, { + input: { itemId }, + }); + return result.getKbItem; } /** - * Get a single collection by ID + * List KB items, one page at a time. */ - async getCollection(id: string): Promise { + async list(params?: PageParams): Promise> { const query = gql` - ${KB_COLLECTION_FRAGMENT} - query GetKbCollection($id: ID!) { - getKbCollection(id: $id) { - ...KbCollectionFields - children { - id - name - description - articleCount + ${KB_ITEM_FRAGMENT} + query GetKbItems($listInfo: ListInfoInput!) { + getKbItems(listInfo: $listInfo) { + kbItems { + ...KbItemFields + } + listInfo { + page + pageSize + totalCount } } } `; - const result = await this.client.query<{ getKbCollection: KbCollection }>(query, { id }); - return result.getKbCollection; + const result = await this.client.query(query, { + listInfo: { + page: params?.page ?? 1, + pageSize: params?.pageSize ?? 50, + }, + }); + + return { + items: result.getKbItems.kbItems, + meta: result.getKbItems.listInfo, + }; } /** - * Search the knowledge base + * Iterate every KB item, fetching pages on demand. */ - async search( - searchQuery: string, - params?: Omit, 'filter'> - ): Promise> { - const query = gql` - ${KB_ARTICLE_FRAGMENT} - query SearchKnowledgeBase( - $query: String! - $first: Int - $after: String - $orderBy: KbArticleOrderInput - ) { - searchKnowledgeBase(query: $query, first: $first, after: $after, orderBy: $orderBy) { - edges { - node { - article { - ...KbArticleFields - } - score - highlights { - title - content - } - } - cursor - } - pageInfo { - hasNextPage - hasPreviousPage - startCursor - endCursor - } - totalCount - } - } - `; - - const variables = { - query: searchQuery, - first: params?.first ?? 50, - after: params?.after, - orderBy: params?.orderBy, - }; - - const result = await this.client.query<{ searchKnowledgeBase: Connection }>( - query, - variables - ); - return result.searchKnowledgeBase; + listAll(params?: { pageSize?: number }): AsyncIterableWithHelpers { + return this.createPageListIterator((p) => this.list(p), params?.pageSize); } /** - * List articles with pagination and filtering + * Create a new knowledge base article. */ - async listArticles( - params?: ListParams - ): Promise> { - const query = gql` - ${KB_ARTICLE_FRAGMENT} - query GetKbArticleList( - $first: Int - $after: String - $filter: KbArticleFilterInput - $orderBy: KbArticleOrderInput - ) { - getKbArticleList(first: $first, after: $after, filter: $filter, orderBy: $orderBy) { - edges { - node { - ...KbArticleFields - } - cursor - } - pageInfo { - hasNextPage - hasPreviousPage - startCursor - endCursor - } - totalCount + async createArticle(input: KbCreateArticleInput): Promise { + const mutation = gql` + ${KB_ITEM_FRAGMENT} + mutation CreateKbArticle($input: CreateKbArticleInput!) { + createKbArticle(input: $input) { + ...KbItemFields } } `; - const variables = { - first: params?.first ?? 50, - after: params?.after, - filter: prepareFilter(params?.filter), - orderBy: params?.orderBy, - }; - - const result = await this.client.query<{ getKbArticleList: Connection }>( - query, - variables - ); - return result.getKbArticleList; - } - - /** - * List all articles with automatic pagination - */ - listArticlesAll( - params?: Omit, 'first' | 'after'> - ): AsyncIterableWithHelpers { - return this.createListIterator( - (p) => this.listArticles(p), - params - ); + const result = await this.client.mutate(mutation, { + input, + }); + return result.createKbArticle; } /** - * Create a new collection + * Update an existing knowledge base article. */ - async createCollection(input: KbCollectionCreateInput): Promise { + async updateArticle(input: KbUpdateArticleInput): Promise { const mutation = gql` - ${KB_COLLECTION_FRAGMENT} - mutation CreateKbCollection($input: KbCollectionInput!) { - createKbCollection(input: $input) { - ...KbCollectionFields + ${KB_ITEM_FRAGMENT} + mutation UpdateKbArticle($input: UpdateKbArticleInput!) { + updateKbArticle(input: $input) { + ...KbItemFields } } `; - const result = await this.client.mutate<{ createKbCollection: KbCollection }>(mutation, { + const result = await this.client.mutate(mutation, { input, }); - return result.createKbCollection; + return result.updateKbArticle; } /** - * Update a collection + * Delete a knowledge base article. */ - async updateCollection(id: string, input: KbCollectionUpdateInput): Promise { + async deleteArticle(input: KbDeleteArticleInput): Promise { const mutation = gql` - ${KB_COLLECTION_FRAGMENT} - mutation UpdateKbCollection($id: ID!, $input: KbCollectionInput!) { - updateKbCollection(id: $id, input: $input) { - ...KbCollectionFields - } + mutation DeleteKbArticle($input: DeleteKbArticleInput!) { + deleteKbArticle(input: $input) } `; - const result = await this.client.mutate<{ updateKbCollection: KbCollection }>(mutation, { - id, + const result = await this.client.mutate(mutation, { input, }); - return result.updateKbCollection; + return result.deleteKbArticle; } /** - * Create a new article + * Create a new knowledge base collection. */ - async createArticle(input: KbArticleCreateInput): Promise { + async createCollection(input: KbCreateCollectionInput): Promise { const mutation = gql` - ${KB_ARTICLE_FRAGMENT} - mutation CreateKbArticle($input: KbArticleInput!) { - createKbArticle(input: $input) { - ...KbArticleFields + ${KB_ITEM_FRAGMENT} + mutation CreateKbCollection($input: CreateKbCollectionInput!) { + createKbCollection(input: $input) { + ...KbItemFields } } `; - const result = await this.client.mutate<{ createKbArticle: KbArticle }>(mutation, { input }); - return result.createKbArticle; + const result = await this.client.mutate(mutation, { + input, + }); + return result.createKbCollection; } /** - * Update an article + * Update an existing knowledge base collection. */ - async updateArticle(id: string, input: KbArticleUpdateInput): Promise { + async updateCollection(input: KbUpdateCollectionInput): Promise { const mutation = gql` - ${KB_ARTICLE_FRAGMENT} - mutation UpdateKbArticle($id: ID!, $input: KbArticleInput!) { - updateKbArticle(id: $id, input: $input) { - ...KbArticleFields + ${KB_ITEM_FRAGMENT} + mutation UpdateKbCollection($input: UpdateKbCollectionInput!) { + updateKbCollection(input: $input) { + ...KbItemFields } } `; - const result = await this.client.mutate<{ updateKbArticle: KbArticle }>(mutation, { - id, + const result = await this.client.mutate(mutation, { input, }); - return result.updateKbArticle; + return result.updateKbCollection; } /** - * Publish an article + * Delete a knowledge base collection. */ - async publishArticle(id: string): Promise { + async deleteCollection(input: KbDeleteCollectionInput): Promise { const mutation = gql` - ${KB_ARTICLE_FRAGMENT} - mutation PublishKbArticle($id: ID!) { - publishKbArticle(id: $id) { - ...KbArticleFields - } + mutation DeleteKbCollection($input: DeleteKbCollectionInput!) { + deleteKbCollection(input: $input) } `; - const result = await this.client.mutate<{ publishKbArticle: KbArticle }>(mutation, { id }); - return result.publishKbArticle; + const result = await this.client.mutate(mutation, { + input, + }); + return result.deleteKbCollection; } } diff --git a/src/resources/patches.ts b/src/resources/patches.ts deleted file mode 100644 index fa1651d..0000000 --- a/src/resources/patches.ts +++ /dev/null @@ -1,281 +0,0 @@ -/** - * Patches resource for SuperOps API - */ - -import { BaseResource, prepareFilter, type BaseResourceOptions } from './base.js'; -import { gql } from '../graphql-client.js'; -import type { - Patch, - PatchDeployment, - PatchDeploymentInput, - PatchComplianceReport, - PatchFilter, - PatchOrderBy, - Connection, - ListParams, - AsyncIterableWithHelpers, -} from '../types/index.js'; - -/** - * GraphQL fragments for patches - */ -const PATCH_FRAGMENT = gql` - fragment PatchFields on Patch { - id - name - title - kbArticleId - description - status - severity - category - releaseDate - vendor - productName - classification - rebootRequired - supersededBy - fileSize - assetId - clientId - installedAt - createdAt - updatedAt - asset { - id - name - } - client { - id - name - } - } -`; - -const DEPLOYMENT_FRAGMENT = gql` - fragment DeploymentFields on PatchDeployment { - id - name - scheduledAt - patchIds - assetIds - status - maintenanceWindowStart - maintenanceWindowEnd - rebootPolicy - createdAt - updatedAt - createdBy { - id - name - } - } -`; - -/** - * Patches resource class - */ -export class PatchesResource extends BaseResource { - constructor(options: BaseResourceOptions) { - super(options); - } - - /** - * List patches with pagination and filtering - */ - async list( - params?: ListParams - ): Promise> { - const query = gql` - ${PATCH_FRAGMENT} - query GetPatchList( - $first: Int - $after: String - $filter: PatchFilterInput - $orderBy: PatchOrderInput - ) { - getPatchList(first: $first, after: $after, filter: $filter, orderBy: $orderBy) { - edges { - node { - ...PatchFields - } - cursor - } - pageInfo { - hasNextPage - hasPreviousPage - startCursor - endCursor - } - totalCount - } - } - `; - - const variables = { - first: params?.first ?? 50, - after: params?.after, - filter: prepareFilter(params?.filter), - orderBy: params?.orderBy, - }; - - const result = await this.client.query<{ getPatchList: Connection }>(query, variables); - return result.getPatchList; - } - - /** - * List all patches with automatic pagination - */ - listAll( - params?: Omit, 'first' | 'after'> - ): AsyncIterableWithHelpers { - return this.createListIterator( - (p) => this.list(p), - params - ); - } - - /** - * List patches for a specific asset - */ - async listByAsset( - assetId: string, - params?: Omit, 'filter'> - ): Promise> { - const query = gql` - ${PATCH_FRAGMENT} - query GetPatchesByAsset( - $assetId: ID! - $first: Int - $after: String - $orderBy: PatchOrderInput - ) { - getPatchesByAsset(assetId: $assetId, first: $first, after: $after, orderBy: $orderBy) { - edges { - node { - ...PatchFields - } - cursor - } - pageInfo { - hasNextPage - hasPreviousPage - startCursor - endCursor - } - totalCount - } - } - `; - - const variables = { - assetId, - first: params?.first ?? 50, - after: params?.after, - orderBy: params?.orderBy, - }; - - const result = await this.client.query<{ getPatchesByAsset: Connection }>( - query, - variables - ); - return result.getPatchesByAsset; - } - - /** - * Get patch compliance report - */ - async getComplianceReport(params?: { - clientId?: string; - siteId?: string; - assetId?: string; - }): Promise { - const query = gql` - query GetPatchComplianceReport($clientId: ID, $siteId: ID, $assetId: ID) { - getPatchComplianceReport(clientId: $clientId, siteId: $siteId, assetId: $assetId) { - generatedAt - scope - scopeId - stats { - totalPatches - installedPatches - pendingPatches - failedPatches - compliancePercentage - criticalPending - importantPending - } - byAsset { - assetId - assetName - stats { - totalPatches - installedPatches - pendingPatches - failedPatches - compliancePercentage - criticalPending - importantPending - } - } - byClient { - clientId - clientName - stats { - totalPatches - installedPatches - pendingPatches - failedPatches - compliancePercentage - criticalPending - importantPending - } - } - } - } - `; - - const result = await this.client.query<{ getPatchComplianceReport: PatchComplianceReport }>( - query, - params - ); - return result.getPatchComplianceReport; - } - - /** - * Approve a patch - */ - async approve(id: string): Promise { - const mutation = gql` - ${PATCH_FRAGMENT} - mutation ApprovePatch($id: ID!) { - approvePatch(id: $id) { - ...PatchFields - } - } - `; - - const result = await this.client.mutate<{ approvePatch: Patch }>(mutation, { id }); - return result.approvePatch; - } - - /** - * Schedule a patch deployment - */ - async scheduleDeployment(input: PatchDeploymentInput): Promise { - const mutation = gql` - ${DEPLOYMENT_FRAGMENT} - mutation SchedulePatchDeployment($input: PatchDeploymentInput!) { - schedulePatchDeployment(input: $input) { - ...DeploymentFields - } - } - `; - - const result = await this.client.mutate<{ schedulePatchDeployment: PatchDeployment }>( - mutation, - { input } - ); - return result.schedulePatchDeployment; - } -} diff --git a/src/resources/remote-sessions.ts b/src/resources/remote-sessions.ts deleted file mode 100644 index 3c1d3a4..0000000 --- a/src/resources/remote-sessions.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * Remote Sessions resource for SuperOps API - */ - -import { BaseResource, type BaseResourceOptions } from './base.js'; -import { gql } from '../graphql-client.js'; -import type { RemoteSession, SessionType } from '../types/index.js'; - -/** - * GraphQL fragments for remote sessions - */ -const REMOTE_SESSION_FRAGMENT = gql` - fragment RemoteSessionFields on RemoteSession { - id - assetId - type - status - connectionUrl - startedAt - terminatedAt - expiresAt - createdAt - updatedAt - initiatedBy { - id - name - } - asset { - id - name - clientId - } - } -`; - -/** - * Remote Sessions resource class - */ -export class RemoteSessionsResource extends BaseResource { - constructor(options: BaseResourceOptions) { - super(options); - } - - /** - * Get a remote session by ID - */ - async get(id: string): Promise { - const query = gql` - ${REMOTE_SESSION_FRAGMENT} - query GetRemoteSession($id: ID!) { - getRemoteSession(id: $id) { - ...RemoteSessionFields - } - } - `; - - const result = await this.client.query<{ getRemoteSession: RemoteSession }>(query, { id }); - return result.getRemoteSession; - } - - /** - * Initiate a new remote session - */ - async initiate(assetId: string, type: SessionType): Promise { - const mutation = gql` - ${REMOTE_SESSION_FRAGMENT} - mutation InitiateRemoteSession($assetId: ID!, $type: SessionType!) { - initiateRemoteSession(assetId: $assetId, type: $type) { - ...RemoteSessionFields - } - } - `; - - const result = await this.client.mutate<{ initiateRemoteSession: RemoteSession }>(mutation, { - assetId, - type, - }); - return result.initiateRemoteSession; - } - - /** - * Terminate a remote session - */ - async terminate(id: string): Promise { - const mutation = gql` - ${REMOTE_SESSION_FRAGMENT} - mutation TerminateRemoteSession($id: ID!) { - terminateRemoteSession(id: $id) { - ...RemoteSessionFields - } - } - `; - - const result = await this.client.mutate<{ terminateRemoteSession: RemoteSession }>(mutation, { - id, - }); - return result.terminateRemoteSession; - } -} diff --git a/src/resources/reports.ts b/src/resources/reports.ts deleted file mode 100644 index 241acbc..0000000 --- a/src/resources/reports.ts +++ /dev/null @@ -1,188 +0,0 @@ -/** - * Reports resource for SuperOps API - */ - -import { BaseResource, type BaseResourceOptions } from './base.js'; -import { gql } from '../graphql-client.js'; -import type { - DateRange, - TicketMetrics, - AssetSummary, - TechnicianPerformance, - ClientHealthScoresResponse, - ReportFilterParams, -} from '../types/index.js'; - -/** - * Reports resource class - */ -export class ReportsResource extends BaseResource { - constructor(options: BaseResourceOptions) { - super(options); - } - - /** - * Get ticket metrics for a date range - */ - async ticketMetrics(dateRange: DateRange, params?: ReportFilterParams): Promise { - const query = gql` - query GetTicketMetrics($dateRange: DateRangeInput!, $clientId: ID, $technicianId: ID) { - getTicketMetrics(dateRange: $dateRange, clientId: $clientId, technicianId: $technicianId) { - period { - startDate - endDate - } - totalTickets - openTickets - resolvedTickets - closedTickets - averageResolutionTimeHours - averageFirstResponseTimeHours - ticketsByPriority { - low - medium - high - critical - } - ticketsByStatus - ticketsBySource - ticketsByType - ticketTrend { - date - created - resolved - } - } - } - `; - - const variables = { - dateRange: { - startDate: - dateRange.startDate instanceof Date - ? dateRange.startDate.toISOString() - : dateRange.startDate, - endDate: - dateRange.endDate instanceof Date ? dateRange.endDate.toISOString() : dateRange.endDate, - }, - clientId: params?.clientId, - technicianId: params?.technicianId, - }; - - const result = await this.client.query<{ getTicketMetrics: TicketMetrics }>(query, variables); - return result.getTicketMetrics; - } - - /** - * Get asset summary - */ - async assetSummary(params?: ReportFilterParams): Promise { - const query = gql` - query GetAssetSummary($clientId: ID, $siteId: ID) { - getAssetSummary(clientId: $clientId, siteId: $siteId) { - totalAssets - activeAssets - inactiveAssets - assetsByType - assetsByStatus - assetsByClient { - clientId - clientName - count - } - assetsByOperatingSystem - recentlyAdded - needingAttention - } - } - `; - - const result = await this.client.query<{ getAssetSummary: AssetSummary }>(query, params as Record); - return result.getAssetSummary; - } - - /** - * Get technician performance metrics - */ - async technicianPerformance(dateRange: DateRange): Promise { - const query = gql` - query GetTechnicianPerformance($dateRange: DateRangeInput!) { - getTechnicianPerformance(dateRange: $dateRange) { - period { - startDate - endDate - } - technicians { - technicianId - technicianName - ticketsAssigned - ticketsResolved - averageResolutionTimeHours - averageFirstResponseTimeHours - totalTimeLoggedHours - customerSatisfactionScore - ticketsByPriority { - low - medium - high - critical - } - } - } - } - `; - - const variables = { - dateRange: { - startDate: - dateRange.startDate instanceof Date - ? dateRange.startDate.toISOString() - : dateRange.startDate, - endDate: - dateRange.endDate instanceof Date ? dateRange.endDate.toISOString() : dateRange.endDate, - }, - }; - - const result = await this.client.query<{ getTechnicianPerformance: TechnicianPerformance }>( - query, - variables - ); - return result.getTechnicianPerformance; - } - - /** - * Get client health scores - */ - async clientHealthScores(params?: ReportFilterParams): Promise { - const query = gql` - query GetClientHealthScores($clientId: ID) { - getClientHealthScores(clientId: $clientId) { - scores { - clientId - clientName - overallScore - components { - assetHealth - ticketVolume - patchCompliance - alertFrequency - contractStatus - } - riskLevel - recommendations - lastUpdatedAt - } - averageScore - atRiskCount - healthyCount - } - } - `; - - const result = await this.client.query<{ getClientHealthScores: ClientHealthScoresResponse }>( - query, - params as Record - ); - return result.getClientHealthScores; - } -} diff --git a/src/resources/runbooks.ts b/src/resources/runbooks.ts deleted file mode 100644 index ff9b01c..0000000 --- a/src/resources/runbooks.ts +++ /dev/null @@ -1,201 +0,0 @@ -/** - * Runbooks resource for SuperOps API - */ - -import { BaseResource, prepareFilter, type BaseResourceOptions } from './base.js'; -import { gql } from '../graphql-client.js'; -import type { - Runbook, - RunbookExecution, - RunbookFilter, - RunbookOrderBy, - Connection, - ListParams, - AsyncIterableWithHelpers, -} from '../types/index.js'; - -/** - * GraphQL fragments for runbooks - */ -const RUNBOOK_FRAGMENT = gql` - fragment RunbookFields on Runbook { - id - name - description - status - category - tags - estimatedDurationMinutes - lastExecutedAt - executionCount - createdAt - updatedAt - createdBy { - id - name - } - steps { - id - name - description - order - type - continueOnError - } - } -`; - -const EXECUTION_FRAGMENT = gql` - fragment ExecutionFields on RunbookExecution { - id - runbookId - status - startedAt - completedAt - targetIds - createdAt - updatedAt - initiatedBy { - id - name - } - results { - targetId - targetName - status - startedAt - completedAt - output - error - } - progress { - total - completed - failed - } - } -`; - -/** - * Runbooks resource class - */ -export class RunbooksResource extends BaseResource { - constructor(options: BaseResourceOptions) { - super(options); - } - - /** - * Get a single runbook by ID - */ - async get(id: string): Promise { - const query = gql` - ${RUNBOOK_FRAGMENT} - query GetRunbook($id: ID!) { - getRunbook(id: $id) { - ...RunbookFields - } - } - `; - - const result = await this.client.query<{ getRunbook: Runbook }>(query, { id }); - return result.getRunbook; - } - - /** - * List runbooks with pagination and filtering - */ - async list( - params?: ListParams - ): Promise> { - const query = gql` - ${RUNBOOK_FRAGMENT} - query GetRunbookList( - $first: Int - $after: String - $filter: RunbookFilterInput - $orderBy: RunbookOrderInput - ) { - getRunbookList(first: $first, after: $after, filter: $filter, orderBy: $orderBy) { - edges { - node { - ...RunbookFields - } - cursor - } - pageInfo { - hasNextPage - hasPreviousPage - startCursor - endCursor - } - totalCount - } - } - `; - - const variables = { - first: params?.first ?? 50, - after: params?.after, - filter: prepareFilter(params?.filter), - orderBy: params?.orderBy, - }; - - const result = await this.client.query<{ getRunbookList: Connection }>( - query, - variables - ); - return result.getRunbookList; - } - - /** - * List all runbooks with automatic pagination - */ - listAll( - params?: Omit, 'first' | 'after'> - ): AsyncIterableWithHelpers { - return this.createListIterator( - (p) => this.list(p), - params - ); - } - - /** - * Execute a runbook on specified targets - */ - async execute(id: string, targetIds: string[]): Promise { - const mutation = gql` - ${EXECUTION_FRAGMENT} - mutation ExecuteRunbook($id: ID!, $targetIds: [ID!]!) { - executeRunbook(id: $id, targetIds: $targetIds) { - ...ExecutionFields - } - } - `; - - const result = await this.client.mutate<{ executeRunbook: RunbookExecution }>(mutation, { - id, - targetIds, - }); - return result.executeRunbook; - } - - /** - * Get runbook execution status - */ - async getExecutionStatus(executionId: string): Promise { - const query = gql` - ${EXECUTION_FRAGMENT} - query GetRunbookExecutionStatus($executionId: ID!) { - getRunbookExecutionStatus(executionId: $executionId) { - ...ExecutionFields - } - } - `; - - const result = await this.client.query<{ getRunbookExecutionStatus: RunbookExecution }>( - query, - { executionId } - ); - return result.getRunbookExecutionStatus; - } -} diff --git a/src/resources/sites.ts b/src/resources/sites.ts index a98623c..fd1d4f5 100644 --- a/src/resources/sites.ts +++ b/src/resources/sites.ts @@ -1,5 +1,5 @@ /** - * Sites resource for SuperOps API + * Sites resource for the SuperOps MSP API. */ import { BaseResource, type BaseResourceOptions } from './base.js'; @@ -8,49 +8,76 @@ import type { Site, SiteCreateInput, SiteUpdateInput, - SiteFilter, - SiteOrderBy, - Connection, - ListParams, + Page, + PageParams, AsyncIterableWithHelpers, } from '../types/index.js'; /** - * GraphQL fragments for sites + * GraphQL selection set for a client site. Fields match the SuperOps `ClientSite` type. */ const SITE_FRAGMENT = gql` - fragment SiteFields on Site { + fragment ClientSiteFields on ClientSite { id name - status - clientId - timezone - notes - createdAt - updatedAt - address { - street1 - street2 - city - state - postalCode - country + timezoneCode + working24x7 + businessHour { + dayOfWeek + startTime + endTime + isWorkingDay } - primaryContact { - email - phone - mobile - fax + holidayList { + id + name } + line1 + line2 + line3 + city + postalCode + countryCode + stateCode + contactNumber client { id name } + hq + installerInfo { + id + name + contactNumber + } } `; +interface GetClientSiteResponse { + getClientSite: Site; +} + +interface GetClientSiteListResponse { + getClientSiteList: { + clientSites: Site[]; + listInfo: { + page: number; + pageSize: number; + totalCount: number; + }; + }; +} + +interface CreateClientSiteResponse { + createClientSite: Site; +} + +interface UpdateClientSiteResponse { + updateClientSite: Site; +} + /** - * Sites resource class + * Sites resource class. */ export class SitesResource extends BaseResource { constructor(options: BaseResourceOptions) { @@ -58,130 +85,99 @@ export class SitesResource extends BaseResource { } /** - * Get a single site by ID + * Get a single client site by its site ID. */ - async get(id: string): Promise { + async get(siteId: string): Promise { const query = gql` ${SITE_FRAGMENT} - query GetSite($id: ID!) { - getSite(id: $id) { - ...SiteFields + query GetClientSite($input: ClientSiteIdentifierInput!) { + getClientSite(input: $input) { + ...ClientSiteFields } } `; - const result = await this.client.query<{ getSite: Site }>(query, { id }); - return result.getSite; + const result = await this.client.query(query, { + input: { siteId }, + }); + return result.getClientSite; } /** - * List sites by client ID + * List client sites, one page at a time. */ - async listByClient( - clientId: string, - params?: Omit, 'filter'> - ): Promise> { + async list(params?: PageParams): Promise> { const query = gql` ${SITE_FRAGMENT} - query GetSitesByClient( - $clientId: ID! - $first: Int - $after: String - $orderBy: SiteOrderInput - ) { - getSitesByClient(clientId: $clientId, first: $first, after: $after, orderBy: $orderBy) { - edges { - node { - ...SiteFields - } - cursor + query GetClientSiteList($input: GetClientSiteListInput!) { + getClientSiteList(input: $input) { + clientSites { + ...ClientSiteFields } - pageInfo { - hasNextPage - hasPreviousPage - startCursor - endCursor + listInfo { + page + pageSize + totalCount } - totalCount } } `; - const variables = { - clientId, - first: params?.first ?? 50, - after: params?.after, - orderBy: params?.orderBy, - }; + const result = await this.client.query(query, { + input: { + page: params?.page ?? 1, + pageSize: params?.pageSize ?? 50, + }, + }); - const result = await this.client.query<{ getSitesByClient: Connection }>( - query, - variables - ); - return result.getSitesByClient; + return { + items: result.getClientSiteList.clientSites, + meta: result.getClientSiteList.listInfo, + }; } /** - * List all sites by client with automatic pagination + * Iterate every client site, fetching pages on demand. */ - listByClientAll( - clientId: string, - params?: Omit, 'first' | 'after' | 'filter'> - ): AsyncIterableWithHelpers { - return this.createListIterator( - (p) => this.listByClient(clientId, { first: p.first, after: p.after, orderBy: p.orderBy }), - params - ); + listAll(params?: { pageSize?: number }): AsyncIterableWithHelpers { + return this.createPageListIterator((p) => this.list(p), params?.pageSize); } /** - * Create a new site for a client + * Create a new client site. */ - async create(clientId: string, input: SiteCreateInput): Promise { + async create(input: SiteCreateInput): Promise { const mutation = gql` ${SITE_FRAGMENT} - mutation CreateClientSite($clientId: ID!, $input: SiteInput!) { - createClientSite(clientId: $clientId, input: $input) { - ...SiteFields + mutation CreateClientSite($input: CreateClientSiteInput!) { + createClientSite(input: $input) { + ...ClientSiteFields } } `; - const result = await this.client.mutate<{ createClientSite: Site }>(mutation, { - clientId, + const result = await this.client.mutate(mutation, { input, }); return result.createClientSite; } /** - * Update an existing site + * Update an existing client site. */ - async update(id: string, input: SiteUpdateInput): Promise { + async update(siteId: string, input: SiteUpdateInput): Promise { const mutation = gql` ${SITE_FRAGMENT} - mutation UpdateSite($id: ID!, $input: SiteInput!) { - updateSite(id: $id, input: $input) { - ...SiteFields + mutation UpdateClientSite($input: UpdateClientSiteInput!) { + updateClientSite(input: $input) { + ...ClientSiteFields } } `; - const result = await this.client.mutate<{ updateSite: Site }>(mutation, { id, input }); - return result.updateSite; - } - - /** - * Delete a site - */ - async delete(id: string): Promise { - const mutation = gql` - mutation DeleteSite($id: ID!) { - deleteSite(id: $id) - } - `; - - const result = await this.client.mutate<{ deleteSite: boolean }>(mutation, { id }); - return result.deleteSite; + const result = await this.client.mutate(mutation, { + input: { siteId, ...input }, + }); + return result.updateClientSite; } } diff --git a/src/resources/technicians.ts b/src/resources/technicians.ts index 7861d81..a2b0337 100644 --- a/src/resources/technicians.ts +++ b/src/resources/technicians.ts @@ -1,50 +1,72 @@ /** - * Technicians resource for SuperOps API + * Technicians resource for the SuperOps MSP API. */ -import { BaseResource, prepareFilter, type BaseResourceOptions } from './base.js'; +import { BaseResource, type BaseResourceOptions } from './base.js'; import { gql } from '../graphql-client.js'; import type { Technician, + TechnicianCreateInput, TechnicianUpdateInput, - TechnicianFilter, - TechnicianOrderBy, - AvailabilitySlot, - Connection, - ListParams, + Page, + PageParams, AsyncIterableWithHelpers, } from '../types/index.js'; /** - * GraphQL fragments for technicians + * GraphQL selection set for a technician. Fields match the SuperOps `Technician` type. + * NOTE: Some field names are unverified against live API and based on common patterns. */ const TECHNICIAN_FRAGMENT = gql` fragment TechnicianFields on Technician { - id - email + userId firstName lastName - name + email + phoneNumber + role { + roleId + name + } status - role - phone - mobile - title + isActive department - timezone - avatarUrl - skills - createdAt - updatedAt - queues { - id + jobTitle + timeZone + groups { + groupId name } + createdDate + modifiedDate } `; +interface GetTechnicianListResponse { + getTechnicianList: { + technicians: Technician[]; + listInfo: { + page: number; + pageSize: number; + totalCount: number; + }; + }; +} + +interface CreateTechnicianResponse { + createTechnician: Technician; +} + +interface UpdateTechnicianResponse { + updateTechnician: Technician; +} + +interface DeleteTechnicianResponse { + deleteTechnician: boolean; +} + /** - * Technicians resource class + * Technicians resource class. */ export class TechniciansResource extends BaseResource { constructor(options: BaseResourceOptions) { @@ -52,122 +74,96 @@ export class TechniciansResource extends BaseResource { } /** - * Get a single technician by ID + * List technicians, one page at a time. */ - async get(id: string): Promise { + async list(params?: PageParams): Promise> { const query = gql` ${TECHNICIAN_FRAGMENT} - query GetTechnician($id: ID!) { - getTechnician(id: $id) { - ...TechnicianFields - } - } - `; - - const result = await this.client.query<{ getTechnician: Technician }>(query, { id }); - return result.getTechnician; - } - - /** - * List technicians with pagination and filtering - */ - async list( - params?: ListParams - ): Promise> { - const query = gql` - ${TECHNICIAN_FRAGMENT} - query GetTechnicianList( - $first: Int - $after: String - $filter: TechnicianFilterInput - $orderBy: TechnicianOrderInput - ) { - getTechnicianList(first: $first, after: $after, filter: $filter, orderBy: $orderBy) { - edges { - node { - ...TechnicianFields - } - cursor + query GetTechnicianList($input: ListInfoInput!) { + getTechnicianList(input: $input) { + technicians { + ...TechnicianFields } - pageInfo { - hasNextPage - hasPreviousPage - startCursor - endCursor + listInfo { + page + pageSize + totalCount } - totalCount } } `; - const variables = { - first: params?.first ?? 50, - after: params?.after, - filter: prepareFilter(params?.filter), - orderBy: params?.orderBy, - }; + const result = await this.client.query(query, { + input: { + page: params?.page ?? 1, + pageSize: params?.pageSize ?? 50, + }, + }); - const result = await this.client.query<{ getTechnicianList: Connection }>( - query, - variables - ); - return result.getTechnicianList; + return { + items: result.getTechnicianList.technicians, + meta: result.getTechnicianList.listInfo, + }; } /** - * List all technicians with automatic pagination + * Iterate every technician, fetching pages on demand. */ - listAll( - params?: Omit, 'first' | 'after'> - ): AsyncIterableWithHelpers { - return this.createListIterator( - (p) => this.list(p), - params - ); + listAll(params?: { pageSize?: number }): AsyncIterableWithHelpers { + return this.createPageListIterator((p) => this.list(p), params?.pageSize); } /** - * Get technician availability for a specific date + * Create a new technician. */ - async getAvailability(id: string, date: string | Date): Promise { - const query = gql` - query GetTechnicianAvailability($id: ID!, $date: Date!) { - getTechnicianAvailability(id: $id, date: $date) { - date - startTime - endTime - available - reason + async create(input: TechnicianCreateInput): Promise { + const mutation = gql` + ${TECHNICIAN_FRAGMENT} + mutation CreateTechnician($input: CreateTechnicianInput!) { + createTechnician(input: $input) { + ...TechnicianFields } } `; - const dateString = date instanceof Date ? date.toISOString().split('T')[0] : date; - - const result = await this.client.query<{ getTechnicianAvailability: AvailabilitySlot[] }>( - query, - { id, date: dateString } - ); - return result.getTechnicianAvailability; + const result = await this.client.mutate(mutation, { + input, + }); + return result.createTechnician; } /** - * Update a technician + * Update an existing technician. */ - async update(id: string, input: TechnicianUpdateInput): Promise { + async update(input: TechnicianUpdateInput): Promise { const mutation = gql` ${TECHNICIAN_FRAGMENT} - mutation UpdateTechnician($id: ID!, $input: TechnicianInput!) { - updateTechnician(id: $id, input: $input) { + mutation UpdateTechnician($input: UpdateTechnicianInput!) { + updateTechnician(input: $input) { ...TechnicianFields } } `; - const result = await this.client.mutate<{ updateTechnician: Technician }>(mutation, { - id, + const result = await this.client.mutate(mutation, { input, }); return result.updateTechnician; } + + /** + * Delete a technician. + */ + async delete(userId: string): Promise { + const mutation = gql` + mutation DeleteTechnician($input: DeleteUserInput!) { + deleteTechnician(input: $input) + } + `; + + const result = await this.client.mutate(mutation, { + input: { userId }, + }); + return result.deleteTechnician; + } } diff --git a/src/resources/tickets.ts b/src/resources/tickets.ts index d680039..d11fa9d 100644 --- a/src/resources/tickets.ts +++ b/src/resources/tickets.ts @@ -1,74 +1,108 @@ /** - * Tickets resource for SuperOps API + * Tickets resource for the SuperOps MSP API. */ -import { BaseResource, prepareFilter, type BaseResourceOptions } from './base.js'; +import { BaseResource, type BaseResourceOptions } from './base.js'; import { gql } from '../graphql-client.js'; import type { Ticket, TicketCreateInput, TicketUpdateInput, - TicketFilter, - TicketOrderBy, - TicketStatus, - TimeEntryInput, - TicketNote, - TicketTimeEntry, - Connection, - ListParams, + Page, + PageParams, AsyncIterableWithHelpers, } from '../types/index.js'; /** - * GraphQL fragments for tickets + * GraphQL selection set for a ticket. Fields match the SuperOps `Ticket` type. */ const TICKET_FRAGMENT = gql` fragment TicketFields on Ticket { - id + ticketId + displayId subject - description - status - priority - type + ticketType + requestType source - dueDate - resolvedAt - closedAt - firstResponseAt - clientId - siteId - assetId - technicianId - queueId - createdAt - updatedAt client { - id + accountId name } site { id name } - asset { + requester { + userId + name + email + } + additionalRequester { + userId + name + email + } + followers + techGroup { id name } technician { - id + userId name email } - queue { + status + priority + impact + urgency + category + subcategory + cause + subcause + resolutionCode + sla { id name } - tags + createdTime + updatedTime + firstResponseDueTime + firstResponseTime + firstResponseViolated + resolutionDueTime + resolutionTime + resolutionViolated + customFields + worklogTimespent } `; +interface GetTicketResponse { + getTicket: Ticket; +} + +interface GetTicketListResponse { + getTicketList: { + tickets: Ticket[]; + listInfo: { + page: number; + pageSize: number; + totalCount: number; + }; + }; +} + +interface CreateTicketResponse { + createTicket: Ticket; +} + +interface UpdateTicketResponse { + updateTicket: Ticket; +} + /** - * Tickets resource class + * Tickets resource class. */ export class TicketsResource extends BaseResource { constructor(options: BaseResourceOptions) { @@ -76,368 +110,99 @@ export class TicketsResource extends BaseResource { } /** - * Get a single ticket by ID + * Get a single ticket by its ticket ID. */ - async get(id: string): Promise { + async get(ticketId: string): Promise { const query = gql` ${TICKET_FRAGMENT} - query GetTicket($id: ID!) { - getTicket(id: $id) { + query GetTicket($input: TicketIdentifierInput!) { + getTicket(input: $input) { ...TicketFields - notes { - id - content - isPublic - createdAt - createdBy { - id - name - } - } - timeEntries { - id - startTime - endTime - durationMinutes - description - billable - technicianId - technician { - id - name - } - } } } `; - const result = await this.client.query<{ getTicket: Ticket }>(query, { id }); + const result = await this.client.query(query, { + input: { ticketId }, + }); return result.getTicket; } /** - * List tickets with pagination and filtering - */ - async list( - params?: ListParams - ): Promise> { - const query = gql` - ${TICKET_FRAGMENT} - query GetTicketList( - $first: Int - $after: String - $filter: TicketFilterInput - $orderBy: TicketOrderInput - ) { - getTicketList(first: $first, after: $after, filter: $filter, orderBy: $orderBy) { - edges { - node { - ...TicketFields - } - cursor - } - pageInfo { - hasNextPage - hasPreviousPage - startCursor - endCursor - } - totalCount - } - } - `; - - const variables = { - first: params?.first ?? 50, - after: params?.after, - filter: prepareFilter(params?.filter), - orderBy: params?.orderBy, - }; - - const result = await this.client.query<{ getTicketList: Connection }>(query, variables); - return result.getTicketList; - } - - /** - * List all tickets with automatic pagination + * List tickets, one page at a time. */ - listAll( - params?: Omit, 'first' | 'after'> - ): AsyncIterableWithHelpers { - return this.createListIterator( - (p) => this.list(p), - params - ); - } - - /** - * List tickets by client ID - */ - async listByClient( - clientId: string, - params?: Omit, 'filter'> - ): Promise> { + async list(params?: PageParams): Promise> { const query = gql` ${TICKET_FRAGMENT} - query GetTicketsByClient( - $clientId: ID! - $first: Int - $after: String - $orderBy: TicketOrderInput - ) { - getTicketsByClient(clientId: $clientId, first: $first, after: $after, orderBy: $orderBy) { - edges { - node { - ...TicketFields - } - cursor + query GetTicketList($input: ListInfoInput!) { + getTicketList(input: $input) { + tickets { + ...TicketFields } - pageInfo { - hasNextPage - hasPreviousPage - startCursor - endCursor + listInfo { + page + pageSize + totalCount } - totalCount } } `; - const variables = { - clientId, - first: params?.first ?? 50, - after: params?.after, - orderBy: params?.orderBy, - }; - - const result = await this.client.query<{ getTicketsByClient: Connection }>( - query, - variables - ); - return result.getTicketsByClient; - } - - /** - * List tickets by status - */ - async listByStatus( - status: TicketStatus, - params?: Omit, 'filter'> - ): Promise> { - const query = gql` - ${TICKET_FRAGMENT} - query GetTicketsByStatus( - $status: TicketStatus! - $first: Int - $after: String - $orderBy: TicketOrderInput - ) { - getTicketsByStatus(status: $status, first: $first, after: $after, orderBy: $orderBy) { - edges { - node { - ...TicketFields - } - cursor - } - pageInfo { - hasNextPage - hasPreviousPage - startCursor - endCursor - } - totalCount - } - } - `; + const result = await this.client.query(query, { + input: { + page: params?.page ?? 1, + pageSize: params?.pageSize ?? 50, + }, + }); - const variables = { - status, - first: params?.first ?? 50, - after: params?.after, - orderBy: params?.orderBy, + return { + items: result.getTicketList.tickets, + meta: result.getTicketList.listInfo, }; - - const result = await this.client.query<{ getTicketsByStatus: Connection }>( - query, - variables - ); - return result.getTicketsByStatus; } /** - * List tickets by technician ID + * Iterate every ticket, fetching pages on demand. */ - async listByTechnician( - technicianId: string, - params?: Omit, 'filter'> - ): Promise> { - const query = gql` - ${TICKET_FRAGMENT} - query GetTicketsByTechnician( - $techId: ID! - $first: Int - $after: String - $orderBy: TicketOrderInput - ) { - getTicketsByTechnician(techId: $techId, first: $first, after: $after, orderBy: $orderBy) { - edges { - node { - ...TicketFields - } - cursor - } - pageInfo { - hasNextPage - hasPreviousPage - startCursor - endCursor - } - totalCount - } - } - `; - - const variables = { - techId: technicianId, - first: params?.first ?? 50, - after: params?.after, - orderBy: params?.orderBy, - }; - - const result = await this.client.query<{ getTicketsByTechnician: Connection }>( - query, - variables - ); - return result.getTicketsByTechnician; + listAll(params?: { pageSize?: number }): AsyncIterableWithHelpers { + return this.createPageListIterator((p) => this.list(p), params?.pageSize); } /** - * Create a new ticket + * Create a new ticket. */ async create(input: TicketCreateInput): Promise { const mutation = gql` ${TICKET_FRAGMENT} - mutation CreateTicket($input: TicketInput!) { + mutation CreateTicket($input: CreateTicketInput!) { createTicket(input: $input) { ...TicketFields } } `; - const result = await this.client.mutate<{ createTicket: Ticket }>(mutation, { input }); - return result.createTicket; - } - - /** - * Update an existing ticket - */ - async update(id: string, input: TicketUpdateInput): Promise { - const mutation = gql` - ${TICKET_FRAGMENT} - mutation UpdateTicket($id: ID!, $input: TicketInput!) { - updateTicket(id: $id, input: $input) { - ...TicketFields - } - } - `; - - const result = await this.client.mutate<{ updateTicket: Ticket }>(mutation, { id, input }); - return result.updateTicket; - } - - /** - * Add a note to a ticket - */ - async addNote(ticketId: string, note: string, isPublic: boolean = false): Promise { - const mutation = gql` - mutation AddTicketNote($ticketId: ID!, $note: String!, $isPublic: Boolean) { - addTicketNote(ticketId: $ticketId, note: $note, isPublic: $isPublic) { - id - content - isPublic - createdAt - createdBy { - id - name - } - } - } - `; - - const result = await this.client.mutate<{ addTicketNote: TicketNote }>(mutation, { - ticketId, - note, - isPublic, - }); - return result.addTicketNote; - } - - /** - * Add a time entry to a ticket - */ - async addTimeEntry(ticketId: string, input: TimeEntryInput): Promise { - const mutation = gql` - mutation AddTicketTimeEntry($ticketId: ID!, $input: TimeEntryInput!) { - addTicketTimeEntry(ticketId: $ticketId, input: $input) { - id - startTime - endTime - durationMinutes - description - billable - technicianId - technician { - id - name - } - } - } - `; - - const result = await this.client.mutate<{ addTicketTimeEntry: TicketTimeEntry }>(mutation, { - ticketId, + const result = await this.client.mutate(mutation, { input, }); - return result.addTicketTimeEntry; - } - - /** - * Change ticket status - */ - async changeStatus(id: string, status: TicketStatus): Promise { - const mutation = gql` - ${TICKET_FRAGMENT} - mutation ChangeTicketStatus($id: ID!, $status: TicketStatus!) { - changeTicketStatus(id: $id, status: $status) { - ...TicketFields - } - } - `; - - const result = await this.client.mutate<{ changeTicketStatus: Ticket }>(mutation, { - id, - status, - }); - return result.changeTicketStatus; + return result.createTicket; } /** - * Assign ticket to a technician + * Update an existing ticket. */ - async assign(id: string, technicianId: string): Promise { + async update(ticketId: string, input: TicketUpdateInput): Promise { const mutation = gql` ${TICKET_FRAGMENT} - mutation AssignTicket($id: ID!, $technicianId: ID!) { - assignTicket(id: $id, technicianId: $technicianId) { + mutation UpdateTicket($input: UpdateTicketInput!) { + updateTicket(input: $input) { ...TicketFields } } `; - const result = await this.client.mutate<{ assignTicket: Ticket }>(mutation, { - id, - technicianId, + const result = await this.client.mutate(mutation, { + input: { ticketId, ...input }, }); - return result.assignTicket; + return result.updateTicket; } -} +} \ No newline at end of file diff --git a/src/types/alerts.ts b/src/types/alerts.ts index b18d0a0..f379c75 100644 --- a/src/types/alerts.ts +++ b/src/types/alerts.ts @@ -1,114 +1,70 @@ /** - * Alert types for SuperOps API + * Alert types for the SuperOps MSP API. + * + * Field names mirror the SuperOps GraphQL `Alert` type. See SCHEMA.md for the + * schema reference these were derived from. */ -import type { BaseResource, OrderDirection } from './common.js'; - -/** - * Alert status - */ -export type AlertStatus = 'OPEN' | 'ACKNOWLEDGED' | 'RESOLVED' | 'DISMISSED'; - -/** - * Alert severity - */ -export type AlertSeverity = 'INFO' | 'WARNING' | 'ERROR' | 'CRITICAL'; - -/** - * Alert category - */ -export type AlertCategory = - | 'SYSTEM' - | 'NETWORK' - | 'SECURITY' - | 'PERFORMANCE' - | 'STORAGE' - | 'APPLICATION' - | 'CUSTOM'; - -/** - * Alert entity - */ -export interface Alert extends BaseResource { - title: string; - message?: string; - status: AlertStatus; - severity: AlertSeverity; - category: AlertCategory; - source?: string; - acknowledgedAt?: string; - resolvedAt?: string; - dismissedAt?: string; +/** Reference to the asset associated with an alert. */ +export interface AlertAssetRef { + // NOTE: unverified against live API - assuming assetId based on other ref patterns assetId?: string; - clientId?: string; - siteId?: string; - ticketId?: string; - asset?: { - id: string; - name: string; - }; - client?: { - id: string; - name: string; - }; - site?: { - id: string; - name: string; - }; - ticket?: { - id: string; - subject: string; - }; - acknowledgedBy?: { - id: string; - name: string; - }; - resolvedBy?: { - id: string; - name: string; - }; - metadata?: Record; + name?: string; } -/** - * Input for creating an alert - */ -export interface AlertCreateInput { - title: string; - message?: string; - severity: AlertSeverity; - category?: AlertCategory; - source?: string; - assetId?: string; - clientId?: string; - siteId?: string; - metadata?: Record; +/** Reference to the monitoring policy that triggered an alert. */ +export interface AlertPolicyRef { + // NOTE: unverified against live API - assuming standard ref pattern + id?: string; + name?: string; } /** - * Alert filter options + * A SuperOps alert. + * + * Field names match the SuperOps GraphQL `Alert` type. */ -export interface AlertFilter { - status?: AlertStatus | AlertStatus[]; - severity?: AlertSeverity | AlertSeverity[]; - category?: AlertCategory | AlertCategory[]; - assetId?: string; - clientId?: string; - siteId?: string; - searchQuery?: string; - createdAfter?: string | Date; - createdBefore?: string | Date; +export interface Alert { + /** Unique alert identifier */ + id: string; + /** Alert title/summary message */ + message: string; + /** Detailed description of the alert */ + description?: string; + /** Alert severity level */ + severity: string; + /** Current alert status */ + status: string; + /** When the alert was created */ + createdTime: string; + /** When the alert was resolved (if applicable) */ + resolvedTime?: string; + /** Associated asset information */ + asset?: AlertAssetRef; + /** Monitoring policy that triggered this alert */ + policy?: AlertPolicyRef; } /** - * Alert order by fields + * Input for creating a new alert. */ -export type AlertOrderField = 'TITLE' | 'STATUS' | 'SEVERITY' | 'CREATED_AT' | 'UPDATED_AT'; +export interface AlertCreateInput { + /** Alert message/title */ + message: string; + /** Detailed description */ + description?: string; + /** Alert severity */ + severity: string; + /** Asset to associate with the alert */ + assetId?: string; + // NOTE: unverified against live API - other fields may be required/available +} /** - * Alert order by configuration + * Input for resolving alerts. */ -export interface AlertOrderBy { - field: AlertOrderField; - direction: OrderDirection; +export interface AlertResolveInput { + /** Alert IDs to resolve */ + alertIds: string[]; + // NOTE: unverified against live API - may have additional fields like resolution reason } diff --git a/src/types/clients.ts b/src/types/clients.ts index f0652e5..8ce4441 100644 --- a/src/types/clients.ts +++ b/src/types/clients.ts @@ -1,130 +1,78 @@ /** - * Client types for SuperOps API + * Client types for the SuperOps MSP API. + * + * Field names mirror the SuperOps GraphQL `Client` type. See SCHEMA.md for the + * schema reference these were derived from. */ -import type { BaseResource, OrderDirection } from './common.js'; - -/** - * Client status - */ -export type ClientStatus = 'ACTIVE' | 'INACTIVE' | 'PROSPECT' | 'ARCHIVED'; - -/** - * Client type - */ -export type ClientType = 'CUSTOMER' | 'PROSPECT' | 'VENDOR' | 'INTERNAL'; +/** Reference to a user associated with a client. */ +export interface ClientUserRef { + userId: string; + name: string; +} -/** - * Contact information - */ -export interface ContactInfo { - email?: string; - phone?: string; - mobile?: string; - fax?: string; +/** Reference to a site associated with a client. */ +export interface ClientSiteRef { + id: string; + name: string; } -/** - * Address information - */ -export interface Address { - street1?: string; - street2?: string; - city?: string; - state?: string; - postalCode?: string; - country?: string; +/** Reference to a technician group associated with a client. */ +export interface ClientTechnicianGroupRef { + id: string; + name: string; } /** - * Client entity + * A SuperOps client (customer/organization). */ -export interface Client extends BaseResource { +export interface Client { + /** Unique client identifier. */ + accountId: string; name: string; - status: ClientStatus; - type: ClientType; - displayName?: string; - website?: string; - industry?: string; - notes?: string; - primaryContact?: ContactInfo; - billingContact?: ContactInfo; - address?: Address; - billingAddress?: Address; - taxId?: string; - defaultTechnicianId?: string; - defaultTechnician?: { - id: string; - name: string; - }; - tags?: string[]; + stage?: string; // NOTE: unverified against live API + status?: string; // NOTE: unverified against live API + emailDomains?: string[]; + accountManager?: ClientUserRef; + primaryContact?: ClientUserRef; + secondaryContact?: ClientUserRef; + hqSite?: ClientSiteRef; + technicianGroups?: ClientTechnicianGroupRef[]; customFields?: Record; } /** - * Input for creating a client + * Input for creating a client. + * + * SuperOps' `createClientV2` mutation accepts these fields. */ export interface ClientCreateInput { name: string; - status?: ClientStatus; - type?: ClientType; - displayName?: string; - website?: string; - industry?: string; - notes?: string; - primaryContact?: ContactInfo; - billingContact?: ContactInfo; - address?: Address; - billingAddress?: Address; - taxId?: string; - defaultTechnicianId?: string; - tags?: string[]; + stage?: string; // NOTE: unverified against live API + status?: string; // NOTE: unverified against live API + emailDomains?: string[]; + accountManagerId?: string; // NOTE: unverified against live API + primaryContactId?: string; // NOTE: unverified against live API + secondaryContactId?: string; // NOTE: unverified against live API + hqSiteId?: string; // NOTE: unverified against live API + technicianGroupIds?: string[]; // NOTE: unverified against live API customFields?: Record; } /** - * Input for updating a client + * Input for updating a client. + * + * SuperOps' `updateClient` mutation accepts these fields. */ export interface ClientUpdateInput { name?: string; - status?: ClientStatus; - type?: ClientType; - displayName?: string; - website?: string; - industry?: string; - notes?: string; - primaryContact?: ContactInfo; - billingContact?: ContactInfo; - address?: Address; - billingAddress?: Address; - taxId?: string; - defaultTechnicianId?: string; - tags?: string[]; + stage?: string; // NOTE: unverified against live API + status?: string; // NOTE: unverified against live API + emailDomains?: string[]; + accountManagerId?: string; // NOTE: unverified against live API + primaryContactId?: string; // NOTE: unverified against live API + secondaryContactId?: string; // NOTE: unverified against live API + hqSiteId?: string; // NOTE: unverified against live API + technicianGroupIds?: string[]; // NOTE: unverified against live API customFields?: Record; } - -/** - * Client filter options - */ -export interface ClientFilter { - status?: ClientStatus | ClientStatus[]; - type?: ClientType | ClientType[]; - searchQuery?: string; - name?: string; - createdAfter?: string | Date; - createdBefore?: string | Date; - tags?: string[]; -} - -/** - * Client order by fields - */ -export type ClientOrderField = 'NAME' | 'STATUS' | 'TYPE' | 'CREATED_AT' | 'UPDATED_AT'; - -/** - * Client order by configuration - */ -export interface ClientOrderBy { - field: ClientOrderField; - direction: OrderDirection; -} diff --git a/src/types/contracts.ts b/src/types/contracts.ts index 1d3058f..e18166b 100644 --- a/src/types/contracts.ts +++ b/src/types/contracts.ts @@ -1,107 +1,82 @@ /** - * Contract types for SuperOps API + * Contract types for the SuperOps MSP API. + * + * Field names mirror the SuperOps GraphQL `ClientContract` type. See SCHEMA.md for the + * schema reference these were derived from. */ -import type { BaseResource, OrderDirection } from './common.js'; +/** Contract status enumeration. */ +export type ContractStatus = 'DRAFT' | 'ACTIVE' | 'EXPIRED' | 'CANCELLED' | 'PENDING'; -/** - * Contract status - */ -export type ContractStatus = 'ACTIVE' | 'EXPIRED' | 'PENDING' | 'CANCELLED'; +/** Contract billing cycle enumeration. */ +export type ContractBillingCycle = 'MONTHLY' | 'QUARTERLY' | 'SEMI_ANNUALLY' | 'ANNUALLY' | 'ONE_TIME'; -/** - * Contract billing cycle - */ -export type BillingCycle = 'MONTHLY' | 'QUARTERLY' | 'SEMI_ANNUALLY' | 'ANNUALLY' | 'ONE_TIME'; +/** Reference to the client that owns a contract. */ +export interface ContractClientRef { + accountId: string; + name: string; +} -/** - * Contract entity - */ -export interface Contract extends BaseResource { +/** Nested contract details within a ClientContract. */ +export interface ContractDetails { name: string; - status: ContractStatus; - clientId: string; + description?: string; startDate: string; endDate?: string; - billingCycle: BillingCycle; - value?: number; + contractStatus: ContractStatus; + contractValue?: number; currency?: string; - description?: string; - autoRenew: boolean; - renewalNotificationDays?: number; - client?: { - id: string; - name: string; - }; + billingCycle?: ContractBillingCycle; + autoRenew?: boolean; customFields?: Record; } /** - * Input for creating a contract + * A SuperOps client contract. + */ +export interface Contract { + /** Unique contract identifier. */ + contractId: number; + client?: ContractClientRef; + contract?: ContractDetails; + startDate: string; + endDate?: string; + contractStatus: ContractStatus; +} + +/** + * Input for creating a client contract. + * + * NOTE: unverified against live API - field names derived from documentation. */ export interface ContractCreateInput { + clientId: string; name: string; - status?: ContractStatus; - startDate: string | Date; - endDate?: string | Date; - billingCycle: BillingCycle; - value?: number; - currency?: string; description?: string; + startDate: string; + endDate?: string; + contractValue?: number; + currency?: string; + billingCycle?: ContractBillingCycle; autoRenew?: boolean; - renewalNotificationDays?: number; customFields?: Record; } /** - * Input for updating a contract + * Input for updating a client contract. + * + * NOTE: unverified against live API - field names derived from documentation. */ export interface ContractUpdateInput { + contractId?: number; name?: string; - status?: ContractStatus; - startDate?: string | Date; - endDate?: string | Date; - billingCycle?: BillingCycle; - value?: number; - currency?: string; description?: string; + startDate?: string; + endDate?: string; + contractValue?: number; + currency?: string; + billingCycle?: ContractBillingCycle; autoRenew?: boolean; - renewalNotificationDays?: number; + contractStatus?: ContractStatus; customFields?: Record; -} - -/** - * Input for renewing a contract - */ -export interface ContractRenewalInput { - newEndDate: string | Date; - newValue?: number; - notes?: string; -} - -/** - * Contract filter options - */ -export interface ContractFilter { - status?: ContractStatus | ContractStatus[]; - clientId?: string; - billingCycle?: BillingCycle | BillingCycle[]; - searchQuery?: string; - expiringBefore?: string | Date; - expiringAfter?: string | Date; - createdAfter?: string | Date; - createdBefore?: string | Date; -} - -/** - * Contract order by fields - */ -export type ContractOrderField = 'NAME' | 'STATUS' | 'START_DATE' | 'END_DATE' | 'CREATED_AT' | 'VALUE'; - -/** - * Contract order by configuration - */ -export interface ContractOrderBy { - field: ContractOrderField; - direction: OrderDirection; -} +} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 442a241..e94957e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -12,7 +12,3 @@ export * from './alerts.js'; export * from './contracts.js'; export * from './technicians.js'; export * from './knowledge-base.js'; -export * from './runbooks.js'; -export * from './patches.js'; -export * from './remote-sessions.js'; -export * from './reports.js'; diff --git a/src/types/knowledge-base.ts b/src/types/knowledge-base.ts index 9b54db2..2c6e320 100644 --- a/src/types/knowledge-base.ts +++ b/src/types/knowledge-base.ts @@ -1,159 +1,120 @@ /** - * Knowledge Base types for SuperOps API + * Knowledge Base types for the SuperOps MSP API. + * + * Field names mirror the SuperOps GraphQL `KbItem` type. See SCHEMA.md for the + * schema reference these were derived from. */ -import type { BaseResource, OrderDirection } from './common.js'; +/** Item type for knowledge base items. */ +export type KbItemType = 'KB_COLLECTION' | 'KB_ARTICLE'; -/** - * Article status - */ -export type ArticleStatus = 'DRAFT' | 'PUBLISHED' | 'ARCHIVED'; +/** Article type enumeration. NOTE: unverified against live API */ +export type KbArticleType = 'INSTRUCTION' | 'TROUBLESHOOTING' | 'FAQ' | 'GENERAL'; -/** - * Article visibility - */ -export type ArticleVisibility = 'INTERNAL' | 'PUBLIC' | 'CLIENT_SPECIFIC'; +/** Article status enumeration. NOTE: unverified against live API */ +export type KbArticleStatus = 'DRAFT' | 'PUBLISHED' | 'ARCHIVED'; -/** - * Knowledge base collection - */ -export interface KbCollection extends BaseResource { +/** Reference to a parent KB item. */ +export interface KbParentRef { + itemId: string; + name: string; +} + +/** Reference to a user who created or modified a KB item. */ +export interface KbUserRef { + userId: string; name: string; - description?: string; - slug?: string; - parentId?: string; - articleCount?: number; - parent?: { - id: string; - name: string; - }; - children?: KbCollection[]; +} + +/** Document sharing/visibility details. NOTE: unverified against live API */ +export interface KbDocumentSharedDetails { + shared: boolean; + sharedWith?: string[]; } /** - * Knowledge base article + * A SuperOps knowledge base item (article or collection). */ -export interface KbArticle extends BaseResource { - title: string; - content: string; - status: ArticleStatus; - visibility: ArticleVisibility; - slug?: string; - excerpt?: string; - collectionId?: string; - collection?: { - id: string; - name: string; - }; - author?: { - id: string; - name: string; - }; - publishedAt?: string; +export interface KbItem { + /** Unique KB item identifier. */ + itemId: string; + name: string; + parent?: KbParentRef; + itemType: KbItemType; + description?: string; + status?: KbArticleStatus; + createdBy?: KbUserRef; + createdOn?: string; + lastModifiedBy?: KbUserRef; + lastModifiedOn?: string; viewCount?: number; - helpfulCount?: number; - notHelpfulCount?: number; - tags?: string[]; - relatedArticleIds?: string[]; - attachments?: Array<{ - id: string; - name: string; - url: string; - size: number; - mimeType: string; - }>; - customFields?: Record; + articleType?: KbArticleType; + visibility?: KbDocumentSharedDetails; + loginRequired?: boolean; } /** - * Input for creating a collection + * Input for creating a knowledge base article. + * NOTE: Field names are unverified against live API */ -export interface KbCollectionCreateInput { +export interface KbCreateArticleInput { name: string; description?: string; - slug?: string; parentId?: string; + articleType?: KbArticleType; + status?: KbArticleStatus; + visibility?: KbDocumentSharedDetails; + loginRequired?: boolean; } /** - * Input for updating a collection + * Input for updating a knowledge base article. + * NOTE: Field names are unverified against live API */ -export interface KbCollectionUpdateInput { +export interface KbUpdateArticleInput { + itemId: string; name?: string; description?: string; - slug?: string; parentId?: string; + articleType?: KbArticleType; + status?: KbArticleStatus; + visibility?: KbDocumentSharedDetails; + loginRequired?: boolean; } /** - * Input for creating an article + * Input for deleting a knowledge base article. + * NOTE: Field names are unverified against live API */ -export interface KbArticleCreateInput { - title: string; - content: string; - status?: ArticleStatus; - visibility?: ArticleVisibility; - slug?: string; - excerpt?: string; - collectionId?: string; - tags?: string[]; - relatedArticleIds?: string[]; - customFields?: Record; +export interface KbDeleteArticleInput { + itemId: string; } /** - * Input for updating an article + * Input for creating a knowledge base collection. + * NOTE: Field names are unverified against live API */ -export interface KbArticleUpdateInput { - title?: string; - content?: string; - status?: ArticleStatus; - visibility?: ArticleVisibility; - slug?: string; - excerpt?: string; - collectionId?: string; - tags?: string[]; - relatedArticleIds?: string[]; - customFields?: Record; -} - -/** - * Knowledge base search result - */ -export interface KbSearchResult { - article: KbArticle; - score: number; - highlights?: { - title?: string; - content?: string; - }; +export interface KbCreateCollectionInput { + name: string; + description?: string; + parentId?: string; } /** - * Article filter options + * Input for updating a knowledge base collection. + * NOTE: Field names are unverified against live API */ -export interface KbArticleFilter { - status?: ArticleStatus | ArticleStatus[]; - visibility?: ArticleVisibility | ArticleVisibility[]; - collectionId?: string; - authorId?: string; - tags?: string[]; - searchQuery?: string; - createdAfter?: string | Date; - createdBefore?: string | Date; - publishedAfter?: string | Date; - publishedBefore?: string | Date; +export interface KbUpdateCollectionInput { + itemId: string; + name?: string; + description?: string; + parentId?: string; } /** - * Article order by fields - */ -export type KbArticleOrderField = 'TITLE' | 'STATUS' | 'CREATED_AT' | 'UPDATED_AT' | 'PUBLISHED_AT' | 'VIEW_COUNT'; - -/** - * Article order by configuration + * Input for deleting a knowledge base collection. + * NOTE: Field names are unverified against live API */ -export interface KbArticleOrderBy { - field: KbArticleOrderField; - direction: OrderDirection; +export interface KbDeleteCollectionInput { + itemId: string; } diff --git a/src/types/patches.ts b/src/types/patches.ts deleted file mode 100644 index c020c24..0000000 --- a/src/types/patches.ts +++ /dev/null @@ -1,153 +0,0 @@ -/** - * Patch management types for SuperOps API - */ - -import type { BaseResource, OrderDirection } from './common.js'; -import type { ExecutionStatus } from './runbooks.js'; - -/** - * Patch status - */ -export type PatchStatus = 'AVAILABLE' | 'APPROVED' | 'DECLINED' | 'INSTALLED' | 'FAILED' | 'PENDING_REBOOT'; - -/** - * Patch severity - */ -export type PatchSeverity = 'CRITICAL' | 'IMPORTANT' | 'MODERATE' | 'LOW' | 'UNSPECIFIED'; - -/** - * Patch category - */ -export type PatchCategory = - | 'SECURITY_UPDATE' - | 'CRITICAL_UPDATE' - | 'FEATURE_PACK' - | 'SERVICE_PACK' - | 'UPDATE_ROLLUP' - | 'UPDATE' - | 'DRIVER' - | 'OTHER'; - -/** - * Patch entity - */ -export interface Patch extends BaseResource { - name: string; - title: string; - kbArticleId?: string; - description?: string; - status: PatchStatus; - severity: PatchSeverity; - category: PatchCategory; - releaseDate?: string; - vendor?: string; - productName?: string; - classification?: string; - rebootRequired: boolean; - supersededBy?: string; - fileSize?: number; - assetId?: string; - clientId?: string; - installedAt?: string; - asset?: { - id: string; - name: string; - }; - client?: { - id: string; - name: string; - }; -} - -/** - * Patch deployment schedule - */ -export interface PatchDeployment extends BaseResource { - name: string; - scheduledAt: string; - patchIds: string[]; - assetIds: string[]; - status: ExecutionStatus; - maintenanceWindowStart?: string; - maintenanceWindowEnd?: string; - rebootPolicy?: 'IMMEDIATE' | 'SCHEDULED' | 'NO_REBOOT'; - createdBy?: { - id: string; - name: string; - }; -} - - -/** - * Patch compliance statistics - */ -export interface PatchComplianceStats { - totalPatches: number; - installedPatches: number; - pendingPatches: number; - failedPatches: number; - compliancePercentage: number; - criticalPending: number; - importantPending: number; -} - -/** - * Patch compliance report - */ -export interface PatchComplianceReport { - generatedAt: string; - scope: 'CLIENT' | 'SITE' | 'ASSET' | 'ALL'; - scopeId?: string; - stats: PatchComplianceStats; - byAsset?: Array<{ - assetId: string; - assetName: string; - stats: PatchComplianceStats; - }>; - byClient?: Array<{ - clientId: string; - clientName: string; - stats: PatchComplianceStats; - }>; -} - -/** - * Input for scheduling patch deployment - */ -export interface PatchDeploymentInput { - name: string; - scheduledAt: string | Date; - patchIds: string[]; - assetIds: string[]; - maintenanceWindowStart?: string | Date; - maintenanceWindowEnd?: string | Date; - rebootPolicy?: 'IMMEDIATE' | 'SCHEDULED' | 'NO_REBOOT'; -} - -/** - * Patch filter options - */ -export interface PatchFilter { - status?: PatchStatus | PatchStatus[]; - severity?: PatchSeverity | PatchSeverity[]; - category?: PatchCategory | PatchCategory[]; - assetId?: string; - clientId?: string; - vendor?: string; - rebootRequired?: boolean; - releasedAfter?: string | Date; - releasedBefore?: string | Date; -} - -/** - * Patch order by fields - */ -export type PatchOrderField = 'NAME' | 'SEVERITY' | 'STATUS' | 'RELEASE_DATE' | 'CREATED_AT'; - -/** - * Patch order by configuration - */ -export interface PatchOrderBy { - field: PatchOrderField; - direction: OrderDirection; -} diff --git a/src/types/remote-sessions.ts b/src/types/remote-sessions.ts deleted file mode 100644 index ec2d25e..0000000 --- a/src/types/remote-sessions.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Remote session types for SuperOps API - */ - -import type { BaseResource } from './common.js'; - -/** - * Session type - */ -export type SessionType = 'REMOTE_DESKTOP' | 'COMMAND_LINE' | 'FILE_TRANSFER' | 'SCREEN_SHARE'; - -/** - * Session status - */ -export type SessionStatus = 'PENDING' | 'ACTIVE' | 'TERMINATED' | 'FAILED' | 'EXPIRED'; - -/** - * Remote session entity - */ -export interface RemoteSession extends BaseResource { - assetId: string; - type: SessionType; - status: SessionStatus; - connectionUrl?: string; - startedAt?: string; - terminatedAt?: string; - expiresAt?: string; - initiatedBy?: { - id: string; - name: string; - }; - asset?: { - id: string; - name: string; - clientId?: string; - }; - metadata?: Record; -} - -/** - * Input for initiating a remote session - */ -export interface RemoteSessionInitiateInput { - assetId: string; - type: SessionType; - expiresInMinutes?: number; - metadata?: Record; -} diff --git a/src/types/reports.ts b/src/types/reports.ts deleted file mode 100644 index b268f23..0000000 --- a/src/types/reports.ts +++ /dev/null @@ -1,118 +0,0 @@ -/** - * Reporting types for SuperOps API - */ - -/** - * Date range for reports - */ -export interface DateRange { - startDate: string | Date; - endDate: string | Date; -} - -/** - * Ticket metrics - */ -export interface TicketMetrics { - period: DateRange; - totalTickets: number; - openTickets: number; - resolvedTickets: number; - closedTickets: number; - averageResolutionTimeHours: number; - averageFirstResponseTimeHours: number; - ticketsByPriority: { - low: number; - medium: number; - high: number; - critical: number; - }; - ticketsByStatus: Record; - ticketsBySource: Record; - ticketsByType: Record; - ticketTrend: Array<{ - date: string; - created: number; - resolved: number; - }>; -} - -/** - * Asset summary - */ -export interface AssetSummary { - totalAssets: number; - activeAssets: number; - inactiveAssets: number; - assetsByType: Record; - assetsByStatus: Record; - assetsByClient: Array<{ - clientId: string; - clientName: string; - count: number; - }>; - assetsByOperatingSystem: Record; - recentlyAdded: number; - needingAttention: number; -} - -/** - * Technician performance metrics - */ -export interface TechnicianPerformance { - period: DateRange; - technicians: Array<{ - technicianId: string; - technicianName: string; - ticketsAssigned: number; - ticketsResolved: number; - averageResolutionTimeHours: number; - averageFirstResponseTimeHours: number; - totalTimeLoggedHours: number; - customerSatisfactionScore?: number; - ticketsByPriority: { - low: number; - medium: number; - high: number; - critical: number; - }; - }>; -} - -/** - * Client health score - */ -export interface ClientHealthScore { - clientId: string; - clientName: string; - overallScore: number; - components: { - assetHealth: number; - ticketVolume: number; - patchCompliance: number; - alertFrequency: number; - contractStatus: number; - }; - riskLevel: 'LOW' | 'MEDIUM' | 'HIGH'; - recommendations: string[]; - lastUpdatedAt: string; -} - -/** - * Client health scores response - */ -export interface ClientHealthScoresResponse { - scores: ClientHealthScore[]; - averageScore: number; - atRiskCount: number; - healthyCount: number; -} - -/** - * Report filter params - */ -export interface ReportFilterParams { - clientId?: string; - siteId?: string; - technicianId?: string; -} diff --git a/src/types/runbooks.ts b/src/types/runbooks.ts deleted file mode 100644 index ec26c33..0000000 --- a/src/types/runbooks.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Runbook types for SuperOps API - */ - -import type { BaseResource, OrderDirection } from './common.js'; - -/** - * Runbook status - */ -export type RunbookStatus = 'ACTIVE' | 'INACTIVE' | 'DRAFT'; - -/** - * Execution status - */ -export type ExecutionStatus = 'PENDING' | 'RUNNING' | 'COMPLETED' | 'FAILED' | 'CANCELLED'; - -/** - * Target execution result - */ -export interface TargetExecutionResult { - targetId: string; - targetName?: string; - status: ExecutionStatus; - startedAt?: string; - completedAt?: string; - output?: string; - error?: string; -} - -/** - * Runbook execution - */ -export interface RunbookExecution extends BaseResource { - runbookId: string; - status: ExecutionStatus; - startedAt: string; - completedAt?: string; - initiatedBy?: { - id: string; - name: string; - }; - targetIds: string[]; - results?: TargetExecutionResult[]; - progress?: { - total: number; - completed: number; - failed: number; - }; -} - -/** - * Runbook step - */ -export interface RunbookStep { - id: string; - name: string; - description?: string; - order: number; - type: string; - config?: Record; - continueOnError?: boolean; -} - -/** - * Runbook entity - */ -export interface Runbook extends BaseResource { - name: string; - description?: string; - status: RunbookStatus; - category?: string; - tags?: string[]; - steps?: RunbookStep[]; - estimatedDurationMinutes?: number; - lastExecutedAt?: string; - executionCount?: number; - createdBy?: { - id: string; - name: string; - }; - customFields?: Record; -} - -/** - * Runbook filter options - */ -export interface RunbookFilter { - status?: RunbookStatus | RunbookStatus[]; - category?: string; - tags?: string[]; - searchQuery?: string; - createdAfter?: string | Date; - createdBefore?: string | Date; -} - -/** - * Runbook order by fields - */ -export type RunbookOrderField = 'NAME' | 'STATUS' | 'CREATED_AT' | 'UPDATED_AT' | 'LAST_EXECUTED_AT'; - -/** - * Runbook order by configuration - */ -export interface RunbookOrderBy { - field: RunbookOrderField; - direction: OrderDirection; -} diff --git a/src/types/sites.ts b/src/types/sites.ts index 6c8f51c..f041cf3 100644 --- a/src/types/sites.ts +++ b/src/types/sites.ts @@ -1,79 +1,112 @@ /** - * Site types for SuperOps API + * Client Site types for the SuperOps MSP API. + * + * Field names mirror the SuperOps GraphQL `ClientSite` type. See SCHEMA.md for the + * schema reference these were derived from. */ -import type { BaseResource, OrderDirection } from './common.js'; -import type { Address, ContactInfo } from './clients.js'; +/** Business hour configuration for a site. */ +export interface SiteBusinessHour { + // NOTE: unverified against live API - business hour structure not detailed in docs + dayOfWeek?: string; + startTime?: string; + endTime?: string; + isWorkingDay?: boolean; +} -/** - * Site status - */ -export type SiteStatus = 'ACTIVE' | 'INACTIVE'; +/** Holiday list configuration for a site. */ +export interface SiteHolidayList { + // NOTE: unverified against live API - holiday structure not detailed in docs + id?: string; + name?: string; +} + +/** Installer information for a site. */ +export interface SiteInstallerInfo { + // NOTE: unverified against live API - installer structure not detailed in docs + id?: string; + name?: string; + contactNumber?: string; +} + +/** Reference to the client that owns a site. */ +export interface SiteClientRef { + // NOTE: unverified against live API - client reference structure assumed + id: string; + name?: string; +} /** - * Site entity + * A SuperOps client site. */ -export interface Site extends BaseResource { +export interface Site { + /** Unique site identifier. */ + id: string; name: string; - status: SiteStatus; - clientId: string; - address?: Address; - primaryContact?: ContactInfo; - notes?: string; - timezone?: string; - client?: { - id: string; - name: string; - }; - customFields?: Record; + timezoneCode?: string; + working24x7?: boolean; + businessHour?: SiteBusinessHour[]; + holidayList?: SiteHolidayList; + + // Address fields + line1?: string; + line2?: string; + line3?: string; + city?: string; + postalCode?: string; + countryCode?: string; + stateCode?: string; + + contactNumber?: string; + client?: SiteClientRef; + hq?: boolean; // is headquarters + installerInfo?: SiteInstallerInfo[]; } /** - * Input for creating a site + * Input for creating a client site. */ export interface SiteCreateInput { name: string; - status?: SiteStatus; - address?: Address; - primaryContact?: ContactInfo; - notes?: string; - timezone?: string; - customFields?: Record; + timezoneCode?: string; + working24x7?: boolean; + + // Address fields + line1?: string; + line2?: string; + line3?: string; + city?: string; + postalCode?: string; + countryCode?: string; + stateCode?: string; + + contactNumber?: string; + hq?: boolean; + // NOTE: unverified against live API - complex nested fields may have different input structure + businessHour?: SiteBusinessHour[]; + installerInfo?: SiteInstallerInfo[]; } /** - * Input for updating a site + * Input for updating a client site. */ export interface SiteUpdateInput { name?: string; - status?: SiteStatus; - address?: Address; - primaryContact?: ContactInfo; - notes?: string; - timezone?: string; - customFields?: Record; -} - -/** - * Site filter options - */ -export interface SiteFilter { - status?: SiteStatus | SiteStatus[]; - clientId?: string; - searchQuery?: string; - createdAfter?: string | Date; - createdBefore?: string | Date; -} + timezoneCode?: string; + working24x7?: boolean; -/** - * Site order by fields - */ -export type SiteOrderField = 'NAME' | 'STATUS' | 'CREATED_AT' | 'UPDATED_AT'; + // Address fields + line1?: string; + line2?: string; + line3?: string; + city?: string; + postalCode?: string; + countryCode?: string; + stateCode?: string; -/** - * Site order by configuration - */ -export interface SiteOrderBy { - field: SiteOrderField; - direction: OrderDirection; + contactNumber?: string; + hq?: boolean; + // NOTE: unverified against live API - complex nested fields may have different input structure + businessHour?: SiteBusinessHour[]; + installerInfo?: SiteInstallerInfo[]; } diff --git a/src/types/technicians.ts b/src/types/technicians.ts index af73b30..f6acfa5 100644 --- a/src/types/technicians.ts +++ b/src/types/technicians.ts @@ -1,90 +1,77 @@ /** - * Technician types for SuperOps API + * Technician types for the SuperOps MSP API. + * + * Field names mirror the SuperOps GraphQL `Technician` type. See SCHEMA.md for the + * schema reference these were derived from. */ -import type { BaseResource, OrderDirection } from './common.js'; - -/** - * Technician status - */ -export type TechnicianStatus = 'ACTIVE' | 'INACTIVE' | 'ON_LEAVE'; +/** Reference to the role a technician has. */ +export interface TechnicianRole { + roleId: string; + name: string; +} -/** - * Technician role - */ -export type TechnicianRole = 'ADMIN' | 'TECHNICIAN' | 'DISPATCHER' | 'READ_ONLY'; +/** Reference to a technician group. */ +export interface TechnicianGroup { + groupId: string; + name: string; +} /** - * Availability slot + * A SuperOps technician (a user who services tickets and manages assets). + * + * NOTE: Some field names are unverified against live API and based on common patterns. */ -export interface AvailabilitySlot { - date: string; - startTime: string; - endTime: string; - available: boolean; - reason?: string; +export interface Technician { + /** Unique technician identifier. */ + userId: string; + firstName: string; + lastName: string; + email: string; + phoneNumber?: string; + role?: TechnicianRole; + status?: string; + isActive?: boolean; + department?: string; + jobTitle?: string; + timeZone?: string; + groups?: TechnicianGroup[]; + createdDate?: string; + modifiedDate?: string; } /** - * Technician entity + * Input for creating a new technician. + * + * NOTE: Field requirements unverified against live API. */ -export interface Technician extends BaseResource { - email: string; +export interface TechnicianCreateInput { firstName: string; lastName: string; - name: string; - status: TechnicianStatus; - role: TechnicianRole; - phone?: string; - mobile?: string; - title?: string; + email: string; + phoneNumber?: string; + roleId?: string; department?: string; - timezone?: string; - avatarUrl?: string; - skills?: string[]; - queues?: Array<{ - id: string; - name: string; - }>; - customFields?: Record; + jobTitle?: string; + timeZone?: string; + groupIds?: string[]; } /** - * Input for updating a technician + * Input for updating an existing technician. + * + * NOTE: Some field names are unverified against live API. */ export interface TechnicianUpdateInput { + userId: string; firstName?: string; lastName?: string; - status?: TechnicianStatus; - phone?: string; - mobile?: string; - title?: string; + email?: string; + phoneNumber?: string; + roleId?: string; department?: string; - timezone?: string; - skills?: string[]; - customFields?: Record; -} - -/** - * Technician filter options - */ -export interface TechnicianFilter { - status?: TechnicianStatus | TechnicianStatus[]; - role?: TechnicianRole | TechnicianRole[]; - searchQuery?: string; - queueId?: string; - skills?: string[]; -} - -/** - * Technician order by fields - */ -export type TechnicianOrderField = 'NAME' | 'EMAIL' | 'STATUS' | 'CREATED_AT'; - -/** - * Technician order by configuration - */ -export interface TechnicianOrderBy { - field: TechnicianOrderField; - direction: OrderDirection; + jobTitle?: string; + timeZone?: string; + groupIds?: string[]; + isActive?: boolean; } diff --git a/src/types/tickets.ts b/src/types/tickets.ts index 58ef13f..eb65c9b 100644 --- a/src/types/tickets.ts +++ b/src/types/tickets.ts @@ -1,197 +1,127 @@ /** - * Ticket types for SuperOps API + * Ticket types for the SuperOps MSP API. + * + * Field names mirror the SuperOps GraphQL `Ticket` type. See SCHEMA.md for the + * schema reference these were derived from. */ -import type { BaseResource, OrderDirection } from './common.js'; - -/** - * Ticket status - */ -export type TicketStatus = - | 'OPEN' - | 'IN_PROGRESS' - | 'WAITING_ON_CLIENT' - | 'WAITING_ON_VENDOR' - | 'SCHEDULED' - | 'RESOLVED' - | 'CLOSED'; - -/** - * Ticket priority - */ -export type TicketPriority = 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL'; +/** Reference to the client that owns a ticket. */ +export interface TicketClientRef { + accountId?: string; + name?: string; +} -/** - * Ticket type - */ -export type TicketType = 'INCIDENT' | 'SERVICE_REQUEST' | 'PROBLEM' | 'CHANGE' | 'TASK'; +/** Reference to the site where a ticket is located. */ +export interface TicketSiteRef { + id?: string; + name?: string; +} -/** - * Ticket source - */ -export type TicketSource = 'EMAIL' | 'PORTAL' | 'PHONE' | 'CHAT' | 'AGENT' | 'API' | 'ALERT'; +/** Reference to a user (requester, technician, etc.). */ +export interface TicketUserRef { + userId?: string; + name?: string; + email?: string; +} -/** - * Ticket note - */ -export interface TicketNote { - id: string; - content: string; - isPublic: boolean; - createdAt: string; - createdBy?: { - id: string; - name: string; - }; +/** Reference to a technician group. */ +export interface TicketTechGroupRef { + id?: string; + name?: string; } -/** - * Ticket time entry - */ -export interface TicketTimeEntry { - id: string; - startTime: string; - endTime?: string; - durationMinutes: number; - description?: string; - billable: boolean; - technicianId: string; - technician?: { - id: string; - name: string; - }; +/** SLA information for a ticket. */ +export interface TicketSLA { + id?: string; + name?: string; + // NOTE: unverified against live API - may have additional fields } /** - * Ticket entity + * A SuperOps ticket (service request/incident). */ -export interface Ticket extends BaseResource { +export interface Ticket { + /** Unique ticket identifier. */ + ticketId: string; + /** Display ID shown in UI. */ + displayId?: string; subject: string; - description?: string; - status: TicketStatus; - priority: TicketPriority; - type: TicketType; - source: TicketSource; - dueDate?: string; - resolvedAt?: string; - closedAt?: string; - firstResponseAt?: string; - clientId?: string; - siteId?: string; - assetId?: string; - technicianId?: string; - queueId?: string; - client?: { - id: string; - name: string; - }; - site?: { - id: string; - name: string; - }; - asset?: { - id: string; - name: string; - }; - technician?: { - id: string; - name: string; - email?: string; - }; - queue?: { - id: string; - name: string; - }; - notes?: TicketNote[]; - timeEntries?: TicketTimeEntry[]; - tags?: string[]; + ticketType?: string; + requestType?: string; + source?: string; + client?: TicketClientRef; + site?: TicketSiteRef; + requester?: TicketUserRef; + additionalRequester?: TicketUserRef[]; + followers?: Record; + techGroup?: TicketTechGroupRef; + technician?: TicketUserRef; + status?: string; + priority?: string; + impact?: string; + urgency?: string; + category?: string; + subcategory?: string; + cause?: string; + subcause?: string; + resolutionCode?: string; + sla?: TicketSLA; + createdTime?: string; + updatedTime?: string; + firstResponseDueTime?: string; + firstResponseTime?: string; + firstResponseViolated?: boolean; + resolutionDueTime?: string; + resolutionTime?: string; + resolutionViolated?: boolean; customFields?: Record; + worklogTimespent?: string; } /** - * Input for creating a ticket + * Input for creating a ticket. */ export interface TicketCreateInput { subject: string; - description?: string; - priority: TicketPriority; - type?: TicketType; - source?: TicketSource; - dueDate?: string | Date; - clientId: string; + ticketType?: string; + requestType?: string; + source?: string; + clientId?: string; siteId?: string; - assetId?: string; + requesterId?: string; technicianId?: string; - queueId?: string; - tags?: string[]; + techGroupId?: string; + priority?: string; + impact?: string; + urgency?: string; + category?: string; + subcategory?: string; customFields?: Record; + // NOTE: unverified against live API - field names may vary } /** - * Input for updating a ticket + * Input for updating a ticket. */ export interface TicketUpdateInput { subject?: string; - description?: string; - priority?: TicketPriority; - type?: TicketType; - dueDate?: string | Date; + ticketType?: string; + requestType?: string; + source?: string; clientId?: string; siteId?: string; - assetId?: string; + requesterId?: string; technicianId?: string; - queueId?: string; - tags?: string[]; + techGroupId?: string; + status?: string; + priority?: string; + impact?: string; + urgency?: string; + category?: string; + subcategory?: string; + cause?: string; + subcause?: string; + resolutionCode?: string; customFields?: Record; -} - -/** - * Input for adding a time entry to a ticket - */ -export interface TimeEntryInput { - startTime: string | Date; - endTime?: string | Date; - durationMinutes?: number; - description?: string; - billable?: boolean; - technicianId?: string; -} - -/** - * Ticket filter options - */ -export interface TicketFilter { - status?: TicketStatus | TicketStatus[]; - priority?: TicketPriority | TicketPriority[]; - type?: TicketType | TicketType[]; - source?: TicketSource | TicketSource[]; - clientId?: string; - siteId?: string; - technicianId?: string; - queueId?: string; - searchQuery?: string; - createdAfter?: string | Date; - createdBefore?: string | Date; - dueBefore?: string | Date; - dueAfter?: string | Date; - tags?: string[]; -} - -/** - * Ticket order by fields - */ -export type TicketOrderField = - | 'SUBJECT' - | 'STATUS' - | 'PRIORITY' - | 'CREATED_AT' - | 'UPDATED_AT' - | 'DUE_DATE'; - -/** - * Ticket order by configuration - */ -export interface TicketOrderBy { - field: TicketOrderField; - direction: OrderDirection; -} + // NOTE: unverified against live API - field names may vary +} \ No newline at end of file diff --git a/tests/integration/alerts.test.ts b/tests/integration/alerts.test.ts index 5c8a311..4a41723 100644 --- a/tests/integration/alerts.test.ts +++ b/tests/integration/alerts.test.ts @@ -14,45 +14,80 @@ describe('Alerts Resource', () => { }); describe('list', () => { - it('should list alerts with pagination info', async () => { + it('should list alerts with pagination metadata', async () => { const result = await client.alerts.list(); - expect(result.edges).toBeDefined(); - expect(result.edges.length).toBeGreaterThan(0); - expect(result.pageInfo).toBeDefined(); + expect(result.items.length).toBeGreaterThan(0); + expect(result.meta.page).toBe(1); + expect(result.meta.totalCount).toBe(2); }); - it('should accept filter parameters', async () => { - const result = await client.alerts.list({ - filter: { - status: 'OPEN', - severity: 'CRITICAL', - }, - }); + it('should accept page parameters', async () => { + const result = await client.alerts.list({ page: 1, pageSize: 10 }); + + expect(result.meta.pageSize).toBe(10); + }); + }); + + describe('listAll', () => { + it('should iterate through all alerts', async () => { + const alerts = []; + + for await (const alert of client.alerts.listAll()) { + alerts.push(alert); + } + + expect(alerts.length).toBe(2); + expect(alerts[0].message).toBe('High CPU Usage Detected'); + expect(alerts[1].message).toBe('Disk Space Low'); + }); + + it('should support toArray()', async () => { + const alerts = await client.alerts.listAll().toArray(); - expect(result.edges).toBeDefined(); + expect(alerts.length).toBe(2); }); }); - describe('acknowledge', () => { - it('should acknowledge an alert', async () => { - const alert = await client.alerts.acknowledge('alert-001'); + describe('listByAsset', () => { + it('should list alerts for a specific asset', async () => { + const result = await client.alerts.listByAsset('asset-123'); - expect(alert.id).toBe('alert-001'); - expect(alert.status).toBe('ACKNOWLEDGED'); - expect(alert.acknowledgedAt).toBeDefined(); - expect(alert.acknowledgedBy).toBeDefined(); + expect(result.items.length).toBe(1); + expect(result.items[0].asset?.assetId).toBe('asset-123'); + }); + + it('should return empty list for asset with no alerts', async () => { + const result = await client.alerts.listByAsset('asset-nonexistent'); + + expect(result.items.length).toBe(0); + }); + }); + + describe('create', () => { + it('should create a new alert', async () => { + const alert = await client.alerts.create({ + message: 'Test Alert', + description: 'This is a test alert', + severity: 'Warning', + assetId: 'asset-123', + }); + + expect(alert.id).toBe('alert-new'); + expect(alert.message).toBe('Test Alert'); + expect(alert.severity).toBe('Warning'); }); }); describe('resolve', () => { - it('should resolve an alert', async () => { - const alert = await client.alerts.resolve('alert-001'); + it('should resolve multiple alerts', async () => { + const alerts = await client.alerts.resolve({ + alertIds: ['alert-123', 'alert-456'], + }); - expect(alert.id).toBe('alert-001'); - expect(alert.status).toBe('RESOLVED'); - expect(alert.resolvedAt).toBeDefined(); - expect(alert.resolvedBy).toBeDefined(); + expect(alerts.length).toBe(2); + expect(alerts[0].status).toBe('Resolved'); + expect(alerts[0].resolvedTime).toBeDefined(); }); }); }); diff --git a/tests/integration/clients.test.ts b/tests/integration/clients.test.ts index d9f5b35..acc0efb 100644 --- a/tests/integration/clients.test.ts +++ b/tests/integration/clients.test.ts @@ -4,9 +4,10 @@ import { describe, it, expect } from 'vitest'; import { SuperOpsClient } from '../../src/client.js'; +import { SuperOpsNotFoundError } from '../../src/errors.js'; describe('Clients Resource', () => { - const superopsClient = new SuperOpsClient({ + const client = new SuperOpsClient({ apiToken: 'test-token', customerSubDomain: 'test-company', region: 'us', @@ -14,47 +15,84 @@ describe('Clients Resource', () => { }); describe('get', () => { - it('should get a single client by ID', async () => { - const clientRecord = await superopsClient.clients.get('client-456'); + it('should get a single client by account ID', async () => { + const clientRecord = await client.clients.get('client-456'); - expect(clientRecord.id).toBe('client-456'); + expect(clientRecord.accountId).toBe('client-456'); expect(clientRecord.name).toBe('Acme Corp'); - expect(clientRecord.status).toBe('ACTIVE'); - expect(clientRecord.type).toBe('CUSTOMER'); - expect(clientRecord.website).toBe('https://acme.example.com'); + expect(clientRecord.stage).toBe('Active'); + expect(clientRecord.status).toBe('Paid'); + expect(clientRecord.emailDomains).toEqual(['acme.com', 'acmetech.com']); + expect(clientRecord.accountManager?.userId).toBe('user-1'); + expect(clientRecord.accountManager?.name).toBe('John Manager'); + }); + + it('should throw NotFoundError for non-existent client', async () => { + await expect(client.clients.get('not-found')).rejects.toThrow(SuperOpsNotFoundError); }); }); describe('list', () => { - it('should list clients with pagination info', async () => { - const result = await superopsClient.clients.list(); + it('should list clients with pagination metadata', async () => { + const result = await client.clients.list(); - expect(result.edges).toBeDefined(); - expect(result.edges.length).toBeGreaterThan(0); - expect(result.pageInfo).toBeDefined(); + expect(result.items.length).toBeGreaterThan(0); + expect(result.meta.page).toBe(1); + expect(result.meta.totalCount).toBe(2); + }); + + it('should accept page parameters', async () => { + const result = await client.clients.list({ page: 1, pageSize: 10 }); + + expect(result.meta.pageSize).toBe(10); }); }); - describe('search', () => { - it('should search for clients by query', async () => { - const result = await superopsClient.clients.search('Acme'); + describe('listAll', () => { + it('should iterate through all clients', async () => { + const clients = []; + + for await (const clientRecord of client.clients.listAll()) { + clients.push(clientRecord); + } - expect(result.edges.length).toBeGreaterThan(0); - expect(result.edges[0].node.name).toContain('Acme'); + expect(clients.length).toBe(2); + expect(clients[0].name).toBe('Acme Corp'); + expect(clients[1].name).toBe('Beta Industries'); }); - it('should return empty results for non-matching query', async () => { - const result = await superopsClient.clients.search('NonExistentCompany123'); + it('should support toArray()', async () => { + const clients = await client.clients.listAll().toArray(); - expect(result.edges.length).toBe(0); + expect(clients.length).toBe(2); }); }); - describe('listAll', () => { - it('should support toArray()', async () => { - const clients = await superopsClient.clients.listAll().toArray(); + describe('create', () => { + it('should create a new client', async () => { + const clientRecord = await client.clients.create({ + name: 'New Client Corp', + stage: 'Prospect', + customFields: { department: 'Engineering' }, + }); + + expect(clientRecord.accountId).toBe('client-new'); + expect(clientRecord.name).toBe('New Client Corp'); + expect(clientRecord.stage).toBe('Prospect'); + expect(clientRecord.customFields).toEqual({ department: 'Engineering' }); + }); + }); + + describe('update', () => { + it("should update a client's fields", async () => { + const clientRecord = await client.clients.update('client-456', { + name: 'Updated Acme Corp', + customFields: { location: 'HQ' }, + }); - expect(clients.length).toBeGreaterThan(0); + expect(clientRecord.accountId).toBe('client-456'); + expect(clientRecord.name).toBe('Updated Acme Corp'); + expect(clientRecord.customFields).toEqual({ location: 'HQ' }); }); }); }); diff --git a/tests/integration/contracts.test.ts b/tests/integration/contracts.test.ts new file mode 100644 index 0000000..610745e --- /dev/null +++ b/tests/integration/contracts.test.ts @@ -0,0 +1,110 @@ +/** + * Contracts integration tests + */ + +import { describe, it, expect } from 'vitest'; +import { SuperOpsClient } from '../../src/client.js'; +import { SuperOpsNotFoundError } from '../../src/errors.js'; + +describe('Contracts Resource', () => { + const client = new SuperOpsClient({ + apiToken: 'test-token', + customerSubDomain: 'test-company', + region: 'us', + vertical: 'msp', + }); + + describe('get', () => { + it('should get a single contract by ID', async () => { + const contract = await client.contracts.get(12345); + + expect(contract.contractId).toBe(12345); + expect(contract.client?.accountId).toBe('client-456'); + expect(contract.client?.name).toBe('Acme Corp'); + expect(contract.contract?.name).toBe('IT Support Contract'); + expect(contract.contractStatus).toBe('ACTIVE'); + expect(contract.startDate).toBe('2026-01-01'); + expect(contract.endDate).toBe('2026-12-31'); + }); + + it('should throw NotFoundError for non-existent contract', async () => { + await expect(client.contracts.get(999)).rejects.toThrow(SuperOpsNotFoundError); + }); + }); + + describe('list', () => { + it('should list contracts with pagination metadata', async () => { + const result = await client.contracts.list(); + + expect(result.items.length).toBeGreaterThan(0); + expect(result.meta.page).toBe(1); + expect(result.meta.totalCount).toBe(2); + }); + + it('should accept page parameters', async () => { + const result = await client.contracts.list({ page: 1, pageSize: 10 }); + + expect(result.meta.pageSize).toBe(10); + }); + }); + + describe('listAll', () => { + it('should iterate through all contracts', async () => { + const contracts = []; + + for await (const contract of client.contracts.listAll()) { + contracts.push(contract); + } + + expect(contracts.length).toBe(2); + expect(contracts[0].contract?.name).toBe('IT Support Contract'); + expect(contracts[1].contract?.name).toBe('Security Services Contract'); + }); + + it('should support toArray()', async () => { + const contracts = await client.contracts.listAll().toArray(); + + expect(contracts.length).toBe(2); + }); + }); + + describe('create', () => { + it('should create a new contract', async () => { + const contractId = await client.contracts.create({ + clientId: 'client-123', + name: 'New Support Contract', + description: 'A new IT support contract', + startDate: '2026-03-01', + endDate: '2027-02-28', + contractValue: 100000, + currency: 'USD', + billingCycle: 'ANNUALLY', + autoRenew: true, + }); + + expect(contractId).toBe('12347'); + }); + }); + + describe('update', () => { + it('should update an existing contract', async () => { + const contract = await client.contracts.update(12345, { + name: 'Updated IT Support Contract', + contractValue: 150000, + contractStatus: 'ACTIVE', + }); + + expect(contract.contractId).toBe(12345); + expect(contract.contract?.name).toBe('Updated IT Support Contract'); + expect(contract.contract?.contractValue).toBe(150000); + }); + + it('should throw NotFoundError for non-existent contract', async () => { + await expect( + client.contracts.update(999, { + name: 'Updated Contract', + }) + ).rejects.toThrow(SuperOpsNotFoundError); + }); + }); +}); \ No newline at end of file diff --git a/tests/integration/knowledge-base.test.ts b/tests/integration/knowledge-base.test.ts new file mode 100644 index 0000000..594e6a7 --- /dev/null +++ b/tests/integration/knowledge-base.test.ts @@ -0,0 +1,198 @@ +/** + * Knowledge Base integration tests + */ + +import { describe, it, expect } from 'vitest'; +import { SuperOpsClient } from '../../src/client.js'; +import { SuperOpsNotFoundError } from '../../src/errors.js'; + +describe('Knowledge Base Resource', () => { + const client = new SuperOpsClient({ + apiToken: 'test-token', + customerSubDomain: 'test-company', + region: 'us', + vertical: 'msp', + }); + + describe('get', () => { + it('should get a single KB collection by ID', async () => { + const kbItem = await client.knowledgeBase.get('kb-collection-123'); + + expect(kbItem.itemId).toBe('kb-collection-123'); + expect(kbItem.name).toBe('General IT Support'); + expect(kbItem.itemType).toBe('KB_COLLECTION'); + expect(kbItem.description).toBe('General IT support documentation and procedures'); + expect(kbItem.status).toBe('PUBLISHED'); + expect(kbItem.createdBy?.name).toBe('Admin User'); + }); + + it('should get a single KB article by ID', async () => { + const kbItem = await client.knowledgeBase.get('kb-article-456'); + + expect(kbItem.itemId).toBe('kb-article-456'); + expect(kbItem.name).toBe('How to Reset Password'); + expect(kbItem.itemType).toBe('KB_ARTICLE'); + expect(kbItem.articleType).toBe('INSTRUCTION'); + expect(kbItem.parent?.itemId).toBe('kb-collection-123'); + expect(kbItem.parent?.name).toBe('General IT Support'); + }); + + it('should throw NotFoundError for non-existent KB item', async () => { + await expect(client.knowledgeBase.get('not-found')).rejects.toThrow(SuperOpsNotFoundError); + }); + }); + + describe('list', () => { + it('should list KB items with pagination metadata', async () => { + const result = await client.knowledgeBase.list(); + + expect(result.items.length).toBeGreaterThan(0); + expect(result.meta.page).toBe(1); + expect(result.meta.totalCount).toBe(3); + + // Should include both collections and articles + const collections = result.items.filter(item => item.itemType === 'KB_COLLECTION'); + const articles = result.items.filter(item => item.itemType === 'KB_ARTICLE'); + expect(collections.length).toBe(1); + expect(articles.length).toBe(2); + }); + + it('should accept page parameters', async () => { + const result = await client.knowledgeBase.list({ page: 1, pageSize: 10 }); + + expect(result.meta.pageSize).toBe(10); + }); + }); + + describe('listAll', () => { + it('should iterate through all KB items', async () => { + const kbItems = []; + + for await (const item of client.knowledgeBase.listAll()) { + kbItems.push(item); + } + + expect(kbItems.length).toBe(3); + expect(kbItems[0].name).toBe('General IT Support'); + expect(kbItems[1].name).toBe('How to Reset Password'); + expect(kbItems[2].name).toBe('Troubleshooting Network Connectivity'); + }); + + it('should support toArray()', async () => { + const kbItems = await client.knowledgeBase.listAll().toArray(); + + expect(kbItems.length).toBe(3); + }); + }); + + describe('createArticle', () => { + it('should create a new KB article', async () => { + const article = await client.knowledgeBase.createArticle({ + name: 'New Article', + description: 'A new knowledge base article', + parentId: 'kb-collection-123', + }); + + expect(article.itemId).toBe('kb-article-new'); + expect(article.name).toBe('New Article'); + expect(article.description).toBe('A new knowledge base article'); + expect(article.itemType).toBe('KB_ARTICLE'); + }); + }); + + describe('updateArticle', () => { + it('should update an existing KB article', async () => { + const article = await client.knowledgeBase.updateArticle({ + itemId: 'kb-article-456', + name: 'Updated Article Name', + description: 'Updated description', + }); + + expect(article.itemId).toBe('kb-article-456'); + expect(article.name).toBe('Updated Article Name'); + expect(article.description).toBe('Updated description'); + }); + + it('should throw NotFoundError for non-existent article', async () => { + await expect( + client.knowledgeBase.updateArticle({ + itemId: 'not-found', + name: 'Updated Name', + }) + ).rejects.toThrow(SuperOpsNotFoundError); + }); + }); + + describe('deleteArticle', () => { + it('should delete a KB article', async () => { + const result = await client.knowledgeBase.deleteArticle({ + itemId: 'kb-article-456', + }); + + expect(result).toBe(true); + }); + + it('should throw NotFoundError for non-existent article', async () => { + await expect( + client.knowledgeBase.deleteArticle({ + itemId: 'not-found', + }) + ).rejects.toThrow(SuperOpsNotFoundError); + }); + }); + + describe('createCollection', () => { + it('should create a new KB collection', async () => { + const collection = await client.knowledgeBase.createCollection({ + name: 'New Collection', + description: 'A new knowledge base collection', + }); + + expect(collection.itemId).toBe('kb-collection-new'); + expect(collection.name).toBe('New Collection'); + expect(collection.description).toBe('A new knowledge base collection'); + expect(collection.itemType).toBe('KB_COLLECTION'); + }); + }); + + describe('updateCollection', () => { + it('should update an existing KB collection', async () => { + const collection = await client.knowledgeBase.updateCollection({ + itemId: 'kb-collection-123', + name: 'Updated Collection Name', + description: 'Updated description', + }); + + expect(collection.itemId).toBe('kb-collection-123'); + expect(collection.name).toBe('Updated Collection Name'); + expect(collection.description).toBe('Updated description'); + }); + + it('should throw NotFoundError for non-existent collection', async () => { + await expect( + client.knowledgeBase.updateCollection({ + itemId: 'not-found', + name: 'Updated Name', + }) + ).rejects.toThrow(SuperOpsNotFoundError); + }); + }); + + describe('deleteCollection', () => { + it('should delete a KB collection', async () => { + const result = await client.knowledgeBase.deleteCollection({ + itemId: 'kb-collection-123', + }); + + expect(result).toBe(true); + }); + + it('should throw NotFoundError for non-existent collection', async () => { + await expect( + client.knowledgeBase.deleteCollection({ + itemId: 'not-found', + }) + ).rejects.toThrow(SuperOpsNotFoundError); + }); + }); +}); \ No newline at end of file diff --git a/tests/integration/sites.test.ts b/tests/integration/sites.test.ts new file mode 100644 index 0000000..a994c16 --- /dev/null +++ b/tests/integration/sites.test.ts @@ -0,0 +1,115 @@ +/** + * Sites integration tests + */ + +import { describe, it, expect } from 'vitest'; +import { SuperOpsClient } from '../../src/client.js'; +import { SuperOpsNotFoundError } from '../../src/errors.js'; + +describe('Sites Resource', () => { + const client = new SuperOpsClient({ + apiToken: 'test-token', + customerSubDomain: 'test-company', + region: 'us', + vertical: 'msp', + }); + + describe('get', () => { + it('should get a single client site by ID', async () => { + const site = await client.sites.get('site-123'); + + expect(site.id).toBe('site-123'); + expect(site.name).toBe('Main Office'); + expect(site.timezoneCode).toBe('America/New_York'); + expect(site.working24x7).toBe(false); + expect(site.hq).toBe(true); + expect(site.client?.id).toBe('client-456'); + expect(site.client?.name).toBe('Acme Corp'); + expect(site.line1).toBe('123 Main Street'); + expect(site.city).toBe('New York'); + expect(site.countryCode).toBe('US'); + }); + + it('should throw NotFoundError for non-existent site', async () => { + await expect(client.sites.get('not-found')).rejects.toThrow(SuperOpsNotFoundError); + }); + }); + + describe('list', () => { + it('should list client sites with pagination metadata', async () => { + const result = await client.sites.list(); + + expect(result.items.length).toBeGreaterThan(0); + expect(result.meta.page).toBe(1); + expect(result.meta.totalCount).toBe(2); + }); + + it('should accept page parameters', async () => { + const result = await client.sites.list({ page: 1, pageSize: 10 }); + + expect(result.meta.pageSize).toBe(10); + }); + }); + + describe('listAll', () => { + it('should iterate through all client sites', async () => { + const sites = []; + + for await (const site of client.sites.listAll()) { + sites.push(site); + } + + expect(sites.length).toBe(2); + expect(sites[0].name).toBe('Main Office'); + expect(sites[1].name).toBe('Branch Office'); + }); + + it('should support toArray()', async () => { + const sites = await client.sites.listAll().toArray(); + + expect(sites.length).toBe(2); + }); + }); + + describe('create', () => { + it('should create a new client site', async () => { + const site = await client.sites.create({ + name: 'New Office', + timezoneCode: 'America/Chicago', + working24x7: false, + line1: '789 New Street', + city: 'Chicago', + countryCode: 'US', + contactNumber: '+1-555-111-2222', + hq: false, + }); + + expect(site.id).toBe('site-new'); + expect(site.name).toBe('New Office'); + expect(site.timezoneCode).toBe('America/Chicago'); + expect(site.working24x7).toBe(false); + expect(site.hq).toBe(false); + }); + }); + + describe('update', () => { + it('should update an existing client site', async () => { + const site = await client.sites.update('site-123', { + name: 'Updated Office Name', + timezoneCode: 'America/Denver', + working24x7: true, + }); + + expect(site.id).toBe('site-123'); + expect(site.name).toBe('Updated Office Name'); + expect(site.timezoneCode).toBe('America/Denver'); + expect(site.working24x7).toBe(true); + }); + + it('should throw NotFoundError for non-existent site', async () => { + await expect( + client.sites.update('not-found', { name: 'Updated' }) + ).rejects.toThrow(SuperOpsNotFoundError); + }); + }); +}); \ No newline at end of file diff --git a/tests/integration/technicians.test.ts b/tests/integration/technicians.test.ts new file mode 100644 index 0000000..7bbe783 --- /dev/null +++ b/tests/integration/technicians.test.ts @@ -0,0 +1,120 @@ +/** + * Technicians integration tests + */ + +import { describe, it, expect } from 'vitest'; +import { SuperOpsClient } from '../../src/client.js'; +import { SuperOpsNotFoundError, SuperOpsValidationError } from '../../src/errors.js'; + +describe('Technicians Resource', () => { + const client = new SuperOpsClient({ + apiToken: 'test-token', + customerSubDomain: 'test-company', + region: 'us', + vertical: 'msp', + }); + + describe('list', () => { + it('should list technicians with pagination metadata', async () => { + const result = await client.technicians.list(); + + expect(result.items.length).toBeGreaterThan(0); + expect(result.meta.page).toBe(1); + expect(result.meta.totalCount).toBe(2); + expect(result.items[0].userId).toBe('tech-123'); + expect(result.items[0].firstName).toBe('John'); + expect(result.items[0].lastName).toBe('Smith'); + expect(result.items[0].email).toBe('john.smith@acme.com'); + }); + + it('should accept page parameters', async () => { + const result = await client.technicians.list({ page: 1, pageSize: 10 }); + + expect(result.meta.pageSize).toBe(10); + }); + }); + + describe('listAll', () => { + it('should iterate through all technicians', async () => { + const technicians = []; + + for await (const technician of client.technicians.listAll()) { + technicians.push(technician); + } + + expect(technicians.length).toBe(2); + expect(technicians[0].firstName).toBe('John'); + expect(technicians[1].firstName).toBe('Sarah'); + }); + + it('should support toArray()', async () => { + const technicians = await client.technicians.listAll().toArray(); + + expect(technicians.length).toBe(2); + }); + }); + + describe('create', () => { + it('should create a new technician', async () => { + const technician = await client.technicians.create({ + firstName: 'Mike', + lastName: 'Wilson', + email: 'mike.wilson@acme.com', + phoneNumber: '+1-555-0789', + department: 'IT Support', + jobTitle: 'Junior Technician', + }); + + expect(technician.userId).toBe('tech-new'); + expect(technician.firstName).toBe('Mike'); + expect(technician.lastName).toBe('Wilson'); + expect(technician.email).toBe('mike.wilson@acme.com'); + expect(technician.isActive).toBe(true); + }); + + it('should handle validation errors', async () => { + await expect( + client.technicians.create({ + firstName: '', + lastName: '', + email: '', + }) + ).rejects.toThrow(SuperOpsValidationError); + }); + }); + + describe('update', () => { + it('should update a technician', async () => { + const technician = await client.technicians.update({ + userId: 'tech-123', + firstName: 'Johnny', + department: 'Advanced Support', + }); + + expect(technician.userId).toBe('tech-123'); + expect(technician.firstName).toBe('Johnny'); + expect(technician.department).toBe('Advanced Support'); + }); + + it('should throw NotFoundError for non-existent technician', async () => { + await expect( + client.technicians.update({ + userId: 'not-found', + firstName: 'Test', + }) + ).rejects.toThrow(SuperOpsNotFoundError); + }); + }); + + describe('delete', () => { + it('should delete a technician', async () => { + const result = await client.technicians.delete('tech-456'); + + expect(result).toBe(true); + }); + + it('should throw NotFoundError for non-existent technician', async () => { + await expect(client.technicians.delete('not-found')).rejects.toThrow(SuperOpsNotFoundError); + }); + }); +}); \ No newline at end of file diff --git a/tests/integration/tickets.test.ts b/tests/integration/tickets.test.ts index 523c92b..aca0785 100644 --- a/tests/integration/tickets.test.ts +++ b/tests/integration/tickets.test.ts @@ -4,6 +4,7 @@ import { describe, it, expect } from 'vitest'; import { SuperOpsClient } from '../../src/client.js'; +import { SuperOpsNotFoundError } from '../../src/errors.js'; describe('Tickets Resource', () => { const client = new SuperOpsClient({ @@ -15,34 +16,55 @@ describe('Tickets Resource', () => { describe('get', () => { it('should get a single ticket by ID', async () => { - const ticket = await client.tickets.get('ticket-001'); + const ticket = await client.tickets.get('ticket-123'); - expect(ticket.id).toBe('ticket-001'); - expect(ticket.subject).toBe('Computer not starting'); + expect(ticket.ticketId).toBe('ticket-123'); + expect(ticket.subject).toBe('Network connectivity issue'); expect(ticket.status).toBe('OPEN'); expect(ticket.priority).toBe('HIGH'); - expect(ticket.type).toBe('INCIDENT'); + expect(ticket.ticketType).toBe('INCIDENT'); + expect(ticket.client?.accountId).toBe('client-456'); + expect(ticket.client?.name).toBe('Acme Corp'); + }); + + it('should throw NotFoundError for non-existent ticket', async () => { + await expect(client.tickets.get('not-found')).rejects.toThrow(SuperOpsNotFoundError); }); }); describe('list', () => { - it('should list tickets with pagination info', async () => { + it('should list tickets with pagination metadata', async () => { const result = await client.tickets.list(); - expect(result.edges).toBeDefined(); - expect(result.edges.length).toBeGreaterThan(0); - expect(result.pageInfo).toBeDefined(); + expect(result.items.length).toBeGreaterThan(0); + expect(result.meta.page).toBe(1); + expect(result.meta.totalCount).toBe(2); }); - it('should accept filter parameters', async () => { - const result = await client.tickets.list({ - filter: { - status: 'OPEN', - priority: 'HIGH', - }, - }); + it('should accept page parameters', async () => { + const result = await client.tickets.list({ page: 1, pageSize: 10 }); - expect(result.edges).toBeDefined(); + expect(result.meta.pageSize).toBe(10); + }); + }); + + describe('listAll', () => { + it('should iterate through all tickets', async () => { + const tickets = []; + + for await (const ticket of client.tickets.listAll()) { + tickets.push(ticket); + } + + expect(tickets.length).toBe(2); + expect(tickets[0].subject).toBe('Network connectivity issue'); + expect(tickets[1].subject).toBe('Software installation request'); + }); + + it('should support toArray()', async () => { + const tickets = await client.tickets.listAll().toArray(); + + expect(tickets.length).toBe(2); }); }); @@ -54,32 +76,21 @@ describe('Tickets Resource', () => { clientId: 'client-456', }); - expect(ticket.id).toBe('ticket-new'); + expect(ticket.ticketId).toBe('ticket-new'); expect(ticket.subject).toBe('New Issue'); }); }); - describe('changeStatus', () => { - it('should change ticket status', async () => { - const ticket = await client.tickets.changeStatus('ticket-001', 'IN_PROGRESS'); - - expect(ticket.status).toBe('IN_PROGRESS'); - }); - }); - - describe('addNote', () => { - it('should add a note to a ticket', async () => { - const note = await client.tickets.addNote('ticket-001', 'This is a test note', false); - - expect(note.id).toBeDefined(); - expect(note.content).toBe('This is a test note'); - expect(note.isPublic).toBe(false); - }); - - it('should add a public note', async () => { - const note = await client.tickets.addNote('ticket-001', 'Public note', true); + describe('update', () => { + it('should update a ticket', async () => { + const ticket = await client.tickets.update('ticket-123', { + subject: 'Updated subject', + customFields: { location: 'Building A' }, + }); - expect(note.isPublic).toBe(true); + expect(ticket.ticketId).toBe('ticket-123'); + expect(ticket.subject).toBe('Updated subject'); + expect(ticket.customFields).toEqual({ location: 'Building A' }); }); }); -}); +}); \ No newline at end of file diff --git a/tests/mocks/handlers.ts b/tests/mocks/handlers.ts index b49fbb2..c50c152 100644 --- a/tests/mocks/handlers.ts +++ b/tests/mocks/handlers.ts @@ -1,601 +1,28 @@ /** - * MSW GraphQL handlers for SuperOps API mocking - */ - -import { graphql, HttpResponse } from 'msw'; - -// Create a GraphQL link for SuperOps API -const superopsApi = graphql.link('https://api.superops.ai/msp'); - -/** - * Sample asset data. Shape matches the SuperOps `Asset` GraphQL type. - */ -const sampleAsset = { - assetId: 'asset-123', - name: 'Test Workstation', - assetClass: { classId: 'class-1', name: 'Workstation' }, - client: { accountId: 'client-456', name: 'Acme Corp' }, - site: { id: 'site-789', name: 'Main Office' }, - requester: { userId: 'user-1', name: 'Jane Doe' }, - primaryMac: '00:11:22:33:44:55', - loggedInUser: 'jdoe', - serialNumber: 'SN123456', - manufacturer: 'Dell', - model: 'OptiPlex 7080', - hostName: 'WS-001', - publicIp: '203.0.113.10', - gateway: '192.168.1.1', - platform: 'Windows 11 Pro', - domain: 'acme.local', - status: 'ACTIVE', - sysUptime: '5 days', - lastCommunicatedTime: '2026-02-04T10:00:00.000Z', - agentVersion: '1.2.3', - platformFamily: 'Windows', - platformCategory: 'Desktop', - platformVersion: '11', - patchStatus: 'UP_TO_DATE', - warrantyExpiryDate: '2028-06-15', - purchasedDate: '2025-06-15', - lastReportedTime: '2026-02-04T10:00:00.000Z', - customFields: {}, -}; - -const sampleAsset2 = { - assetId: 'asset-456', - name: 'Test Server', - assetClass: { classId: 'class-2', name: 'Server' }, - client: { accountId: 'client-456', name: 'Acme Corp' }, - site: { id: 'site-789', name: 'Main Office' }, - requester: null, - primaryMac: 'AA:BB:CC:DD:EE:FF', - loggedInUser: null, - serialNumber: 'SN789012', - manufacturer: 'HP', - model: 'ProLiant DL380', - hostName: 'SRV-001', - publicIp: '203.0.113.11', - gateway: '192.168.1.1', - platform: 'Windows Server 2022', - domain: 'acme.local', - status: 'ACTIVE', - sysUptime: '120 days', - lastCommunicatedTime: '2026-02-04T10:00:00.000Z', - agentVersion: '1.2.3', - platformFamily: 'Windows', - platformCategory: 'Server', - platformVersion: '2022', - patchStatus: 'PENDING', - warrantyExpiryDate: null, - purchasedDate: '2025-01-10', - lastReportedTime: '2026-02-04T10:00:00.000Z', - customFields: {}, -}; - -/** - * Sample ticket data - */ -const sampleTicket = { - id: 'ticket-001', - subject: 'Computer not starting', - description: 'User reports computer will not boot', - status: 'OPEN', - priority: 'HIGH', - type: 'INCIDENT', - source: 'EMAIL', - dueDate: '2026-02-05T17:00:00.000Z', - resolvedAt: null, - closedAt: null, - firstResponseAt: null, - clientId: 'client-456', - siteId: 'site-789', - assetId: 'asset-123', - technicianId: 'tech-001', - queueId: 'queue-001', - createdAt: '2026-02-04T09:00:00.000Z', - updatedAt: '2026-02-04T09:00:00.000Z', - client: { - id: 'client-456', - name: 'Acme Corp', - }, - site: { - id: 'site-789', - name: 'Main Office', - }, - asset: { - id: 'asset-123', - name: 'Test Workstation', - }, - technician: { - id: 'tech-001', - name: 'John Smith', - email: 'john.smith@msp.com', - }, - queue: { - id: 'queue-001', - name: 'Tier 1 Support', - }, - tags: ['hardware', 'urgent'], - notes: [], - timeEntries: [], -}; - -/** - * Sample client data - */ -const sampleClient = { - id: 'client-456', - name: 'Acme Corp', - status: 'ACTIVE', - type: 'CUSTOMER', - displayName: 'Acme Corporation', - website: 'https://acme.example.com', - industry: 'Manufacturing', - notes: 'Important customer', - taxId: '12-3456789', - defaultTechnicianId: 'tech-001', - createdAt: '2024-01-15T10:00:00.000Z', - updatedAt: '2026-01-20T14:00:00.000Z', - primaryContact: { - email: 'contact@acme.example.com', - phone: '555-123-4567', - mobile: '555-987-6543', - fax: null, - }, - billingContact: { - email: 'billing@acme.example.com', - phone: '555-123-4568', - mobile: null, - fax: null, - }, - address: { - street1: '123 Main St', - street2: 'Suite 100', - city: 'Anytown', - state: 'CA', - postalCode: '12345', - country: 'USA', - }, - billingAddress: { - street1: '123 Main St', - street2: 'Suite 100', - city: 'Anytown', - state: 'CA', - postalCode: '12345', - country: 'USA', - }, - defaultTechnician: { - id: 'tech-001', - name: 'John Smith', - }, - tags: ['enterprise', 'priority'], -}; - -/** - * Sample alert data - */ -const sampleAlert = { - id: 'alert-001', - title: 'High CPU Usage', - message: 'CPU usage exceeded 90% for 5 minutes', - status: 'OPEN', - severity: 'WARNING', - category: 'PERFORMANCE', - source: 'RMM Agent', - acknowledgedAt: null, - resolvedAt: null, - dismissedAt: null, - assetId: 'asset-123', - clientId: 'client-456', - siteId: 'site-789', - ticketId: null, - createdAt: '2026-02-04T08:30:00.000Z', - updatedAt: '2026-02-04T08:30:00.000Z', - asset: { - id: 'asset-123', - name: 'Test Workstation', - }, - client: { - id: 'client-456', - name: 'Acme Corp', - }, - site: { - id: 'site-789', - name: 'Main Office', - }, - ticket: null, - acknowledgedBy: null, - resolvedBy: null, -}; + * MSW GraphQL handlers for the SuperOps API. + * + * Handlers are split per resource under ./handlers/. This file only + * aggregates them. + */ + +import { assetHandlers } from './handlers/assets.js'; +import { ticketHandlers } from './handlers/tickets.js'; +import { clientHandlers } from './handlers/clients.js'; +import { siteHandlers } from './handlers/sites.js'; +import { alertHandlers } from './handlers/alerts.js'; +import { contractHandlers } from './handlers/contracts.js'; +import { technicianHandlers } from './handlers/technicians.js'; +import { knowledgeBaseHandlers } from './handlers/knowledge-base.js'; +import { miscHandlers } from './handlers/misc.js'; -/** - * Error responses - */ -const notFoundError = { - errors: [ - { - message: 'Resource not found', - extensions: { - code: 'NOT_FOUND', - }, - }, - ], -}; - -const authenticationError = { - errors: [ - { - message: 'Invalid API token', - extensions: { - code: 'UNAUTHENTICATED', - }, - }, - ], -}; - -const validationError = { - errors: [ - { - message: 'Name is required', - path: ['input', 'name'], - extensions: { - code: 'BAD_USER_INPUT', - }, - }, - ], -}; - -/** - * Helper to check authentication - */ -function checkAuth(request: Request): boolean { - const authHeader = request.headers.get('Authorization'); - const subdomainHeader = request.headers.get('CustomerSubDomain'); - - return ( - authHeader !== null && - authHeader.startsWith('Bearer ') && - authHeader !== 'Bearer invalid-token' && - subdomainHeader !== null && - subdomainHeader.length > 0 - ); -} - -/** - * MSW handlers for SuperOps GraphQL API - */ export const handlers = [ - // Assets - Get single - superopsApi.query('GetAsset', ({ request, variables }) => { - if (!checkAuth(request)) { - return HttpResponse.json(authenticationError); - } - - const { input } = variables as { input: { assetId: string } }; - if (input.assetId === 'asset-123') { - return HttpResponse.json({ data: { getAsset: sampleAsset } }); - } - return HttpResponse.json(notFoundError); - }), - - // Assets - List (page-based) - superopsApi.query('GetAssetList', ({ request, variables }) => { - if (!checkAuth(request)) { - return HttpResponse.json(authenticationError); - } - - const { input } = variables as { input: { page?: number; pageSize?: number } }; - const page = input.page ?? 1; - const pageSize = input.pageSize ?? 50; - - // Both sample assets fit on the first page. - const assets = page === 1 ? [sampleAsset, sampleAsset2] : []; - - return HttpResponse.json({ - data: { - getAssetList: { - assets, - listInfo: { page, pageSize, totalCount: 2 }, - }, - }, - }); - }), - - // Assets - Update - superopsApi.mutation('UpdateAsset', ({ request, variables }) => { - if (!checkAuth(request)) { - return HttpResponse.json(authenticationError); - } - - const { input } = variables as { - input: { assetId: string; customFields?: Record }; - }; - if (input.assetId === 'not-found') { - return HttpResponse.json(notFoundError); - } - - return HttpResponse.json({ - data: { - updateAsset: { - ...sampleAsset, - assetId: input.assetId, - customFields: input.customFields ?? sampleAsset.customFields, - }, - }, - }); - }), - - // Tickets - Get single - superopsApi.query('GetTicket', ({ request, variables }) => { - if (!checkAuth(request)) { - return HttpResponse.json(authenticationError); - } - - const { id } = variables as { id: string }; - if (id === 'ticket-001') { - return HttpResponse.json({ data: { getTicket: sampleTicket } }); - } - return HttpResponse.json(notFoundError); - }), - - // Tickets - List - superopsApi.query('GetTicketList', ({ request }) => { - if (!checkAuth(request)) { - return HttpResponse.json(authenticationError); - } - - return HttpResponse.json({ - data: { - getTicketList: { - edges: [{ node: sampleTicket, cursor: 'cursor-1' }], - pageInfo: { - hasNextPage: false, - hasPreviousPage: false, - startCursor: 'cursor-1', - endCursor: 'cursor-1', - }, - totalCount: 1, - }, - }, - }); - }), - - // Tickets - Create - superopsApi.mutation('CreateTicket', ({ request, variables }) => { - if (!checkAuth(request)) { - return HttpResponse.json(authenticationError); - } - - const { input } = variables as { input: { subject?: string } }; - if (!input.subject) { - return HttpResponse.json(validationError); - } - - return HttpResponse.json({ - data: { - createTicket: { - ...sampleTicket, - id: 'ticket-new', - ...input, - }, - }, - }); - }), - - // Tickets - Change Status - superopsApi.mutation('ChangeTicketStatus', ({ request, variables }) => { - if (!checkAuth(request)) { - return HttpResponse.json(authenticationError); - } - - const { id, status } = variables as { id: string; status: string }; - return HttpResponse.json({ - data: { - changeTicketStatus: { - ...sampleTicket, - id, - status, - }, - }, - }); - }), - - // Tickets - Add Note - superopsApi.mutation('AddTicketNote', ({ request, variables }) => { - if (!checkAuth(request)) { - return HttpResponse.json(authenticationError); - } - - const { ticketId: _ticketId, note, isPublic } = variables as { - ticketId: string; - note: string; - isPublic?: boolean; - }; - - return HttpResponse.json({ - data: { - addTicketNote: { - id: 'note-new', - content: note, - isPublic: isPublic ?? false, - createdAt: '2026-02-04T12:00:00.000Z', - createdBy: { - id: 'tech-001', - name: 'John Smith', - }, - }, - }, - }); - }), - - // Clients - Get single - superopsApi.query('GetClient', ({ request, variables }) => { - if (!checkAuth(request)) { - return HttpResponse.json(authenticationError); - } - - const { id } = variables as { id: string }; - if (id === 'client-456') { - return HttpResponse.json({ data: { getClient: sampleClient } }); - } - return HttpResponse.json(notFoundError); - }), - - // Clients - List - superopsApi.query('GetClientList', ({ request }) => { - if (!checkAuth(request)) { - return HttpResponse.json(authenticationError); - } - - return HttpResponse.json({ - data: { - getClientList: { - edges: [{ node: sampleClient, cursor: 'cursor-1' }], - pageInfo: { - hasNextPage: false, - hasPreviousPage: false, - startCursor: 'cursor-1', - endCursor: 'cursor-1', - }, - totalCount: 1, - }, - }, - }); - }), - - // Clients - Search - superopsApi.query('SearchClients', ({ request, variables }) => { - if (!checkAuth(request)) { - return HttpResponse.json(authenticationError); - } - - const { query } = variables as { query: string }; - if (query.toLowerCase().includes('acme')) { - return HttpResponse.json({ - data: { - searchClients: { - edges: [{ node: sampleClient, cursor: 'cursor-1' }], - pageInfo: { - hasNextPage: false, - hasPreviousPage: false, - startCursor: 'cursor-1', - endCursor: 'cursor-1', - }, - totalCount: 1, - }, - }, - }); - } - - return HttpResponse.json({ - data: { - searchClients: { - edges: [], - pageInfo: { - hasNextPage: false, - hasPreviousPage: false, - startCursor: null, - endCursor: null, - }, - totalCount: 0, - }, - }, - }); - }), - - // Alerts - List - superopsApi.query('GetAlertList', ({ request }) => { - if (!checkAuth(request)) { - return HttpResponse.json(authenticationError); - } - - return HttpResponse.json({ - data: { - getAlertList: { - edges: [{ node: sampleAlert, cursor: 'cursor-1' }], - pageInfo: { - hasNextPage: false, - hasPreviousPage: false, - startCursor: 'cursor-1', - endCursor: 'cursor-1', - }, - totalCount: 1, - }, - }, - }); - }), - - // Alerts - Acknowledge - superopsApi.mutation('AcknowledgeAlert', ({ request, variables }) => { - if (!checkAuth(request)) { - return HttpResponse.json(authenticationError); - } - - const { id } = variables as { id: string }; - return HttpResponse.json({ - data: { - acknowledgeAlert: { - ...sampleAlert, - id, - status: 'ACKNOWLEDGED', - acknowledgedAt: '2026-02-04T12:00:00.000Z', - acknowledgedBy: { - id: 'tech-001', - name: 'John Smith', - }, - }, - }, - }); - }), - - // Alerts - Resolve - superopsApi.mutation('ResolveAlert', ({ request, variables }) => { - if (!checkAuth(request)) { - return HttpResponse.json(authenticationError); - } - - const { id } = variables as { id: string }; - return HttpResponse.json({ - data: { - resolveAlert: { - ...sampleAlert, - id, - status: 'RESOLVED', - resolvedAt: '2026-02-04T12:00:00.000Z', - resolvedBy: { - id: 'tech-001', - name: 'John Smith', - }, - }, - }, - }); - }), - - // Rate limit simulation - superopsApi.query('RateLimited', () => { - return HttpResponse.json({ - errors: [ - { - message: 'Rate limit exceeded', - extensions: { - code: 'RATE_LIMITED', - }, - }, - ], - }); - }), - - // Server error simulation - superopsApi.query('ServerError', () => { - return HttpResponse.json({ - errors: [ - { - message: 'Internal server error', - extensions: { - code: 'INTERNAL_SERVER_ERROR', - }, - }, - ], - }); - }), + ...assetHandlers, + ...ticketHandlers, + ...clientHandlers, + ...siteHandlers, + ...alertHandlers, + ...contractHandlers, + ...technicianHandlers, + ...knowledgeBaseHandlers, + ...miscHandlers, ]; diff --git a/tests/mocks/handlers/alerts.ts b/tests/mocks/handlers/alerts.ts new file mode 100644 index 0000000..cca90de --- /dev/null +++ b/tests/mocks/handlers/alerts.ts @@ -0,0 +1,142 @@ +/** + * MSW handlers for the SuperOps Alerts API. + */ + +import { HttpResponse } from 'msw'; +import { superopsApi, checkAuth, authenticationError, notFoundError } from '../shared.js'; + +/** Sample alert data. Shape matches the SuperOps `Alert` GraphQL type. */ +const sampleAlert = { + id: 'alert-123', + message: 'High CPU Usage Detected', + description: 'CPU usage has been above 90% for the last 5 minutes', + severity: 'Critical', + status: 'Active', + createdTime: '2026-05-20T10:00:00.000Z', + resolvedTime: null, + asset: { + assetId: 'asset-123', + name: 'Test Workstation', + }, + policy: { + id: 'policy-456', + name: 'CPU Monitoring Policy', + }, +}; + +const sampleAlert2 = { + id: 'alert-456', + message: 'Disk Space Low', + description: 'Available disk space is below 10%', + severity: 'Warning', + status: 'Resolved', + createdTime: '2026-05-20T09:00:00.000Z', + resolvedTime: '2026-05-20T09:30:00.000Z', + asset: { + assetId: 'asset-456', + name: 'Test Server', + }, + policy: { + id: 'policy-789', + name: 'Disk Space Monitoring', + }, +}; + +export const alertHandlers = [ + // Alerts - List (page-based) + superopsApi.query('GetAlertList', ({ request, variables }) => { + if (!checkAuth(request)) { + return HttpResponse.json(authenticationError); + } + + const { input } = variables as { input: { page?: number; pageSize?: number } }; + const page = input.page ?? 1; + const pageSize = input.pageSize ?? 50; + const alerts = page === 1 ? [sampleAlert, sampleAlert2] : []; + + return HttpResponse.json({ + data: { + getAlertList: { + alerts, + listInfo: { page, pageSize, totalCount: 2 }, + }, + }, + }); + }), + + // Alerts - List by asset + superopsApi.query('GetAlertsForAsset', ({ request, variables }) => { + if (!checkAuth(request)) { + return HttpResponse.json(authenticationError); + } + + const { input } = variables as { + input: { assetId: string; page?: number; pageSize?: number } + }; + const page = input.page ?? 1; + const pageSize = input.pageSize ?? 50; + + // Return alerts for specific asset + const alerts = input.assetId === 'asset-123' && page === 1 ? [sampleAlert] : []; + + return HttpResponse.json({ + data: { + getAlertsForAsset: { + alerts, + listInfo: { page, pageSize, totalCount: alerts.length }, + }, + }, + }); + }), + + // Alerts - Create + superopsApi.mutation('CreateAlert', ({ request, variables }) => { + if (!checkAuth(request)) { + return HttpResponse.json(authenticationError); + } + + const { input } = variables as { + input: { message: string; description?: string; severity: string; assetId?: string }; + }; + + return HttpResponse.json({ + data: { + createAlert: { + ...sampleAlert, + id: 'alert-new', + message: input.message, + description: input.description, + severity: input.severity, + asset: input.assetId ? { assetId: input.assetId, name: 'Test Asset' } : null, + }, + }, + }); + }), + + // Alerts - Resolve + superopsApi.mutation('ResolveAlerts', ({ request, variables }) => { + if (!checkAuth(request)) { + return HttpResponse.json(authenticationError); + } + + const { input } = variables as { input: { alertIds: string[] } }; + + if (input.alertIds.includes('not-found')) { + return HttpResponse.json(notFoundError); + } + + // Return resolved alerts + const resolvedAlerts = input.alertIds.map(id => ({ + ...sampleAlert, + id, + status: 'Resolved', + resolvedTime: '2026-05-20T12:00:00.000Z', + })); + + return HttpResponse.json({ + data: { + resolveAlerts: resolvedAlerts, + }, + }); + }), +]; diff --git a/tests/mocks/handlers/assets.ts b/tests/mocks/handlers/assets.ts new file mode 100644 index 0000000..3052418 --- /dev/null +++ b/tests/mocks/handlers/assets.ts @@ -0,0 +1,129 @@ +/** + * MSW handlers for the SuperOps Assets API. + */ + +import { HttpResponse } from 'msw'; +import { superopsApi, checkAuth, authenticationError, notFoundError } from '../shared.js'; + +/** Sample asset data. Shape matches the SuperOps `Asset` GraphQL type. */ +const sampleAsset = { + assetId: 'asset-123', + name: 'Test Workstation', + assetClass: { classId: 'class-1', name: 'Workstation' }, + client: { accountId: 'client-456', name: 'Acme Corp' }, + site: { id: 'site-789', name: 'Main Office' }, + requester: { userId: 'user-1', name: 'Jane Doe' }, + primaryMac: '00:11:22:33:44:55', + loggedInUser: 'jdoe', + serialNumber: 'SN123456', + manufacturer: 'Dell', + model: 'OptiPlex 7080', + hostName: 'WS-001', + publicIp: '203.0.113.10', + gateway: '192.168.1.1', + platform: 'Windows 11 Pro', + domain: 'acme.local', + status: 'ACTIVE', + sysUptime: '5 days', + lastCommunicatedTime: '2026-02-04T10:00:00.000Z', + agentVersion: '1.2.3', + platformFamily: 'Windows', + platformCategory: 'Desktop', + platformVersion: '11', + patchStatus: 'UP_TO_DATE', + warrantyExpiryDate: '2028-06-15', + purchasedDate: '2025-06-15', + lastReportedTime: '2026-02-04T10:00:00.000Z', + customFields: {}, +}; + +const sampleAsset2 = { + assetId: 'asset-456', + name: 'Test Server', + assetClass: { classId: 'class-2', name: 'Server' }, + client: { accountId: 'client-456', name: 'Acme Corp' }, + site: { id: 'site-789', name: 'Main Office' }, + requester: null, + primaryMac: 'AA:BB:CC:DD:EE:FF', + loggedInUser: null, + serialNumber: 'SN789012', + manufacturer: 'HP', + model: 'ProLiant DL380', + hostName: 'SRV-001', + publicIp: '203.0.113.11', + gateway: '192.168.1.1', + platform: 'Windows Server 2022', + domain: 'acme.local', + status: 'ACTIVE', + sysUptime: '120 days', + lastCommunicatedTime: '2026-02-04T10:00:00.000Z', + agentVersion: '1.2.3', + platformFamily: 'Windows', + platformCategory: 'Server', + platformVersion: '2022', + patchStatus: 'PENDING', + warrantyExpiryDate: null, + purchasedDate: '2025-01-10', + lastReportedTime: '2026-02-04T10:00:00.000Z', + customFields: {}, +}; + +export const assetHandlers = [ + // Assets - Get single + superopsApi.query('GetAsset', ({ request, variables }) => { + if (!checkAuth(request)) { + return HttpResponse.json(authenticationError); + } + + const { input } = variables as { input: { assetId: string } }; + if (input.assetId === 'asset-123') { + return HttpResponse.json({ data: { getAsset: sampleAsset } }); + } + return HttpResponse.json(notFoundError); + }), + + // Assets - List (page-based) + superopsApi.query('GetAssetList', ({ request, variables }) => { + if (!checkAuth(request)) { + return HttpResponse.json(authenticationError); + } + + const { input } = variables as { input: { page?: number; pageSize?: number } }; + const page = input.page ?? 1; + const pageSize = input.pageSize ?? 50; + const assets = page === 1 ? [sampleAsset, sampleAsset2] : []; + + return HttpResponse.json({ + data: { + getAssetList: { + assets, + listInfo: { page, pageSize, totalCount: 2 }, + }, + }, + }); + }), + + // Assets - Update + superopsApi.mutation('UpdateAsset', ({ request, variables }) => { + if (!checkAuth(request)) { + return HttpResponse.json(authenticationError); + } + + const { input } = variables as { + input: { assetId: string; customFields?: Record }; + }; + if (input.assetId === 'not-found') { + return HttpResponse.json(notFoundError); + } + + return HttpResponse.json({ + data: { + updateAsset: { + ...sampleAsset, + assetId: input.assetId, + customFields: input.customFields ?? sampleAsset.customFields, + }, + }, + }); + }), +]; diff --git a/tests/mocks/handlers/clients.ts b/tests/mocks/handlers/clients.ts new file mode 100644 index 0000000..9de3f9d --- /dev/null +++ b/tests/mocks/handlers/clients.ts @@ -0,0 +1,124 @@ +/** + * MSW handlers for the SuperOps Clients API. + */ + +import { HttpResponse } from 'msw'; +import { superopsApi, checkAuth, authenticationError, notFoundError } from '../shared.js'; + +/** Sample client data. Shape matches the SuperOps `Client` GraphQL type. */ +const sampleClient = { + accountId: 'client-456', + name: 'Acme Corp', + stage: 'Active', + status: 'Paid', + emailDomains: ['acme.com', 'acmetech.com'], + accountManager: { userId: 'user-1', name: 'John Manager' }, + primaryContact: { userId: 'user-2', name: 'Jane Contact' }, + secondaryContact: { userId: 'user-3', name: 'Bob Secondary' }, + hqSite: { id: 'site-789', name: 'Acme HQ' }, + technicianGroups: [ + { id: 'group-1', name: 'Level 1 Support' }, + { id: 'group-2', name: 'Level 2 Support' }, + ], + customFields: {}, +}; + +const sampleClient2 = { + accountId: 'client-789', + name: 'Beta Industries', + stage: 'Active', + status: 'Paid', + emailDomains: ['beta-industries.com'], + accountManager: { userId: 'user-1', name: 'John Manager' }, + primaryContact: { userId: 'user-4', name: 'Alice Beta' }, + secondaryContact: null, + hqSite: { id: 'site-456', name: 'Beta Main Office' }, + technicianGroups: [ + { id: 'group-1', name: 'Level 1 Support' }, + ], + customFields: {}, +}; + +export const clientHandlers = [ + // Clients - Get single + superopsApi.query('GetClient', ({ request, variables }) => { + if (!checkAuth(request)) { + return HttpResponse.json(authenticationError); + } + + const { input } = variables as { input: { accountId: string } }; + if (input.accountId === 'client-456') { + return HttpResponse.json({ data: { getClient: sampleClient } }); + } + return HttpResponse.json(notFoundError); + }), + + // Clients - List (page-based) + superopsApi.query('GetClientList', ({ request, variables }) => { + if (!checkAuth(request)) { + return HttpResponse.json(authenticationError); + } + + const { input } = variables as { input: { page?: number; pageSize?: number } }; + const page = input.page ?? 1; + const pageSize = input.pageSize ?? 50; + const clients = page === 1 ? [sampleClient, sampleClient2] : []; + + return HttpResponse.json({ + data: { + getClientList: { + clients, + listInfo: { page, pageSize, totalCount: 2 }, + }, + }, + }); + }), + + // Clients - Create + superopsApi.mutation('CreateClientV2', ({ request, variables }) => { + if (!checkAuth(request)) { + return HttpResponse.json(authenticationError); + } + + const { input } = variables as { + input: { name: string; stage?: string; customFields?: Record }; + }; + + return HttpResponse.json({ + data: { + createClientV2: { + ...sampleClient, + accountId: 'client-new', + name: input.name, + stage: input.stage ?? 'Active', + customFields: input.customFields ?? {}, + }, + }, + }); + }), + + // Clients - Update + superopsApi.mutation('UpdateClient', ({ request, variables }) => { + if (!checkAuth(request)) { + return HttpResponse.json(authenticationError); + } + + const { input } = variables as { + input: { accountId: string; name?: string; customFields?: Record }; + }; + if (input.accountId === 'not-found') { + return HttpResponse.json(notFoundError); + } + + return HttpResponse.json({ + data: { + updateClient: { + ...sampleClient, + accountId: input.accountId, + name: input.name ?? sampleClient.name, + customFields: input.customFields ?? sampleClient.customFields, + }, + }, + }); + }), +]; diff --git a/tests/mocks/handlers/contracts.ts b/tests/mocks/handlers/contracts.ts new file mode 100644 index 0000000..00b95dd --- /dev/null +++ b/tests/mocks/handlers/contracts.ts @@ -0,0 +1,161 @@ +/** + * MSW handlers for the SuperOps Client Contracts API. + */ + +import { HttpResponse } from 'msw'; +import { superopsApi, checkAuth, authenticationError, notFoundError, validationError } from '../shared.js'; + +/** Sample contract data. Shape matches the SuperOps `ClientContract` GraphQL type. */ +const sampleContract = { + contractId: 12345, + client: { accountId: 'client-456', name: 'Acme Corp' }, + contract: { + name: 'IT Support Contract', + description: 'Comprehensive IT support for Acme Corp', + startDate: '2026-01-01', + endDate: '2026-12-31', + contractStatus: 'ACTIVE', + contractValue: 120000, + currency: 'USD', + billingCycle: 'MONTHLY', + autoRenew: true, + customFields: {}, + }, + startDate: '2026-01-01', + endDate: '2026-12-31', + contractStatus: 'ACTIVE', +}; + +const sampleContract2 = { + contractId: 12346, + client: { accountId: 'client-789', name: 'TechStart Inc' }, + contract: { + name: 'Security Services Contract', + description: 'Cybersecurity monitoring and response', + startDate: '2026-02-01', + endDate: '2027-01-31', + contractStatus: 'DRAFT', + contractValue: 84000, + currency: 'USD', + billingCycle: 'ANNUALLY', + autoRenew: false, + customFields: { priority: 'high' }, + }, + startDate: '2026-02-01', + endDate: '2027-01-31', + contractStatus: 'DRAFT', +}; + +export const contractHandlers = [ + // Contracts - Get single + superopsApi.query('GetClientContract', ({ request, variables }) => { + if (!checkAuth(request)) { + return HttpResponse.json(authenticationError); + } + + const { input } = variables as { input: { contractId: number } }; + if (input.contractId === 12345) { + return HttpResponse.json({ data: { getClientContract: sampleContract } }); + } + return HttpResponse.json(notFoundError); + }), + + // Contracts - List (page-based) + superopsApi.query('GetClientContractList', ({ request, variables }) => { + if (!checkAuth(request)) { + return HttpResponse.json(authenticationError); + } + + const { input } = variables as { input?: { page?: number; pageSize?: number } } | undefined; + const page = input?.page ?? 1; + const pageSize = input?.pageSize ?? 50; + const clientContracts = page === 1 ? [sampleContract, sampleContract2] : []; + + return HttpResponse.json({ + data: { + getClientContractList: { + clientContracts, + listInfo: { page, pageSize, totalCount: 2 }, + }, + }, + }); + }), + + // Contracts - Create + superopsApi.mutation('CreateClientContract', ({ request, variables }) => { + if (!checkAuth(request)) { + return HttpResponse.json(authenticationError); + } + + const { input } = variables as { + input: { + clientId: string; + name: string; + description?: string; + startDate: string; + endDate?: string; + contractValue?: number; + currency?: string; + billingCycle?: string; + autoRenew?: boolean; + customFields?: Record; + }; + }; + + if (!input.name || !input.clientId || !input.startDate) { + return HttpResponse.json(validationError); + } + + // Return new contract ID + return HttpResponse.json({ + data: { + createClientContract: '12347', + }, + }); + }), + + // Contracts - Update + superopsApi.mutation('UpdateClientContract', ({ request, variables }) => { + if (!checkAuth(request)) { + return HttpResponse.json(authenticationError); + } + + const { input } = variables as { + input: { + contractId: number; + name?: string; + description?: string; + startDate?: string; + endDate?: string; + contractValue?: number; + currency?: string; + billingCycle?: string; + autoRenew?: boolean; + contractStatus?: string; + customFields?: Record; + }; + }; + + if (input.contractId === 999) { + return HttpResponse.json(notFoundError); + } + + return HttpResponse.json({ + data: { + updateClientContract: { + ...sampleContract, + contractId: input.contractId, + contract: { + ...sampleContract.contract, + name: input.name ?? sampleContract.contract.name, + description: input.description ?? sampleContract.contract.description, + contractValue: input.contractValue ?? sampleContract.contract.contractValue, + contractStatus: input.contractStatus ?? sampleContract.contract.contractStatus, + customFields: input.customFields ?? sampleContract.contract.customFields, + }, + contractStatus: input.contractStatus ?? sampleContract.contractStatus, + }, + }, + }); + }), +]; \ No newline at end of file diff --git a/tests/mocks/handlers/knowledge-base.ts b/tests/mocks/handlers/knowledge-base.ts new file mode 100644 index 0000000..2ccbe45 --- /dev/null +++ b/tests/mocks/handlers/knowledge-base.ts @@ -0,0 +1,243 @@ +/** + * MSW handlers for the SuperOps Knowledge Base API. + */ + +import { HttpResponse } from 'msw'; +import { superopsApi, checkAuth, authenticationError, notFoundError, validationError } from '../shared.js'; + +/** Sample KB collection data. Shape matches the SuperOps `KbItem` GraphQL type. */ +const sampleCollection = { + itemId: 'kb-collection-123', + name: 'General IT Support', + parent: null, + itemType: 'KB_COLLECTION', + description: 'General IT support documentation and procedures', + status: 'PUBLISHED', + createdBy: { userId: 'user-1', name: 'Admin User' }, + createdOn: '2026-01-15T10:00:00.000Z', + lastModifiedBy: { userId: 'user-1', name: 'Admin User' }, + lastModifiedOn: '2026-01-15T10:00:00.000Z', + viewCount: 42, + articleType: null, + visibility: { shared: true, sharedWith: ['all'] }, + loginRequired: false, +}; + +/** Sample KB article data. Shape matches the SuperOps `KbItem` GraphQL type. */ +const sampleArticle = { + itemId: 'kb-article-456', + name: 'How to Reset Password', + parent: { itemId: 'kb-collection-123', name: 'General IT Support' }, + itemType: 'KB_ARTICLE', + description: 'Step-by-step guide for resetting user passwords in Active Directory', + status: 'PUBLISHED', + createdBy: { userId: 'user-2', name: 'Tech Writer' }, + createdOn: '2026-01-20T14:30:00.000Z', + lastModifiedBy: { userId: 'user-2', name: 'Tech Writer' }, + lastModifiedOn: '2026-02-01T09:15:00.000Z', + viewCount: 128, + articleType: 'INSTRUCTION', + visibility: { shared: true, sharedWith: ['technicians'] }, + loginRequired: true, +}; + +const sampleArticle2 = { + itemId: 'kb-article-789', + name: 'Troubleshooting Network Connectivity', + parent: { itemId: 'kb-collection-123', name: 'General IT Support' }, + itemType: 'KB_ARTICLE', + description: 'Common network connectivity issues and their solutions', + status: 'DRAFT', + createdBy: { userId: 'user-3', name: 'Network Admin' }, + createdOn: '2026-02-03T11:00:00.000Z', + lastModifiedBy: { userId: 'user-3', name: 'Network Admin' }, + lastModifiedOn: '2026-02-03T16:45:00.000Z', + viewCount: 5, + articleType: 'TROUBLESHOOTING', + visibility: { shared: false, sharedWith: [] }, + loginRequired: false, +}; + +export const knowledgeBaseHandlers = [ + // KB Items - Get single + superopsApi.query('GetKbItem', ({ request, variables }) => { + if (!checkAuth(request)) { + return HttpResponse.json(authenticationError); + } + + const { input } = variables as { input: { itemId: string } }; + if (input.itemId === 'kb-collection-123') { + return HttpResponse.json({ data: { getKbItem: sampleCollection } }); + } + if (input.itemId === 'kb-article-456') { + return HttpResponse.json({ data: { getKbItem: sampleArticle } }); + } + if (input.itemId === 'kb-article-789') { + return HttpResponse.json({ data: { getKbItem: sampleArticle2 } }); + } + return HttpResponse.json(notFoundError); + }), + + // KB Items - List (page-based) + superopsApi.query('GetKbItems', ({ request, variables }) => { + if (!checkAuth(request)) { + return HttpResponse.json(authenticationError); + } + + const { listInfo } = variables as { listInfo: { page?: number; pageSize?: number } }; + const page = listInfo.page ?? 1; + const pageSize = listInfo.pageSize ?? 50; + const kbItems = page === 1 ? [sampleCollection, sampleArticle, sampleArticle2] : []; + + return HttpResponse.json({ + data: { + getKbItems: { + kbItems, + listInfo: { page, pageSize, totalCount: 3 }, + }, + }, + }); + }), + + // KB Article - Create + superopsApi.mutation('CreateKbArticle', ({ request, variables }) => { + if (!checkAuth(request)) { + return HttpResponse.json(authenticationError); + } + + const { input } = variables as { + input: { name: string; description?: string; parentId?: string }; + }; + + if (!input.name) { + return HttpResponse.json(validationError); + } + + return HttpResponse.json({ + data: { + createKbArticle: { + ...sampleArticle, + itemId: 'kb-article-new', + name: input.name, + description: input.description, + parent: input.parentId ? { itemId: input.parentId, name: 'Parent Collection' } : null, + }, + }, + }); + }), + + // KB Article - Update + superopsApi.mutation('UpdateKbArticle', ({ request, variables }) => { + if (!checkAuth(request)) { + return HttpResponse.json(authenticationError); + } + + const { input } = variables as { + input: { itemId: string; name?: string; description?: string }; + }; + + if (input.itemId === 'not-found') { + return HttpResponse.json(notFoundError); + } + + return HttpResponse.json({ + data: { + updateKbArticle: { + ...sampleArticle, + itemId: input.itemId, + name: input.name ?? sampleArticle.name, + description: input.description ?? sampleArticle.description, + lastModifiedOn: new Date().toISOString(), + }, + }, + }); + }), + + // KB Article - Delete + superopsApi.mutation('DeleteKbArticle', ({ request, variables }) => { + if (!checkAuth(request)) { + return HttpResponse.json(authenticationError); + } + + const { input } = variables as { input: { itemId: string } }; + + if (input.itemId === 'not-found') { + return HttpResponse.json(notFoundError); + } + + return HttpResponse.json({ + data: { deleteKbArticle: true }, + }); + }), + + // KB Collection - Create + superopsApi.mutation('CreateKbCollection', ({ request, variables }) => { + if (!checkAuth(request)) { + return HttpResponse.json(authenticationError); + } + + const { input } = variables as { + input: { name: string; description?: string; parentId?: string }; + }; + + if (!input.name) { + return HttpResponse.json(validationError); + } + + return HttpResponse.json({ + data: { + createKbCollection: { + ...sampleCollection, + itemId: 'kb-collection-new', + name: input.name, + description: input.description, + parent: input.parentId ? { itemId: input.parentId, name: 'Parent Collection' } : null, + }, + }, + }); + }), + + // KB Collection - Update + superopsApi.mutation('UpdateKbCollection', ({ request, variables }) => { + if (!checkAuth(request)) { + return HttpResponse.json(authenticationError); + } + + const { input } = variables as { + input: { itemId: string; name?: string; description?: string }; + }; + + if (input.itemId === 'not-found') { + return HttpResponse.json(notFoundError); + } + + return HttpResponse.json({ + data: { + updateKbCollection: { + ...sampleCollection, + itemId: input.itemId, + name: input.name ?? sampleCollection.name, + description: input.description ?? sampleCollection.description, + lastModifiedOn: new Date().toISOString(), + }, + }, + }); + }), + + // KB Collection - Delete + superopsApi.mutation('DeleteKbCollection', ({ request, variables }) => { + if (!checkAuth(request)) { + return HttpResponse.json(authenticationError); + } + + const { input } = variables as { input: { itemId: string } }; + + if (input.itemId === 'not-found') { + return HttpResponse.json(notFoundError); + } + + return HttpResponse.json({ + data: { deleteKbCollection: true }, + }); + }), +]; diff --git a/tests/mocks/handlers/misc.ts b/tests/mocks/handlers/misc.ts new file mode 100644 index 0000000..2e6a696 --- /dev/null +++ b/tests/mocks/handlers/misc.ts @@ -0,0 +1,24 @@ +/** + * MSW handlers for non-resource test scenarios (rate limiting, server errors). + */ + +import { HttpResponse } from 'msw'; +import { superopsApi } from '../shared.js'; + +export const miscHandlers = [ + // Rate limit simulation + superopsApi.query('RateLimited', () => { + return HttpResponse.json({ + errors: [{ message: 'Rate limit exceeded', extensions: { code: 'RATE_LIMITED' } }], + }); + }), + + // Server error simulation + superopsApi.query('ServerError', () => { + return HttpResponse.json({ + errors: [ + { message: 'Internal server error', extensions: { code: 'INTERNAL_SERVER_ERROR' } }, + ], + }); + }), +]; diff --git a/tests/mocks/handlers/sites.ts b/tests/mocks/handlers/sites.ts new file mode 100644 index 0000000..07325e5 --- /dev/null +++ b/tests/mocks/handlers/sites.ts @@ -0,0 +1,190 @@ +/** + * MSW handlers for the SuperOps Client Sites API. + */ + +import { HttpResponse } from 'msw'; +import { superopsApi, checkAuth, authenticationError, notFoundError } from '../shared.js'; + +/** Sample client site data. Shape matches the SuperOps `ClientSite` GraphQL type. */ +const sampleSite = { + id: 'site-123', + name: 'Main Office', + timezoneCode: 'America/New_York', + working24x7: false, + businessHour: [ + { + dayOfWeek: 'MONDAY', + startTime: '09:00', + endTime: '17:00', + isWorkingDay: true, + }, + { + dayOfWeek: 'TUESDAY', + startTime: '09:00', + endTime: '17:00', + isWorkingDay: true, + }, + ], + holidayList: { + id: 'holidays-1', + name: 'US Federal Holidays', + }, + line1: '123 Main Street', + line2: 'Suite 100', + line3: null, + city: 'New York', + postalCode: '10001', + countryCode: 'US', + stateCode: 'NY', + contactNumber: '+1-555-123-4567', + client: { + id: 'client-456', + name: 'Acme Corp', + }, + hq: true, + installerInfo: [ + { + id: 'installer-1', + name: 'Tech Install Co', + contactNumber: '+1-555-987-6543', + }, + ], +}; + +const sampleSite2 = { + id: 'site-456', + name: 'Branch Office', + timezoneCode: 'America/Los_Angeles', + working24x7: true, + businessHour: [], + holidayList: null, + line1: '456 Oak Avenue', + line2: null, + line3: null, + city: 'Los Angeles', + postalCode: '90210', + countryCode: 'US', + stateCode: 'CA', + contactNumber: '+1-555-246-8135', + client: { + id: 'client-456', + name: 'Acme Corp', + }, + hq: false, + installerInfo: [], +}; + +export const siteHandlers = [ + // Client Sites - Get single + superopsApi.query('GetClientSite', ({ request, variables }) => { + if (!checkAuth(request)) { + return HttpResponse.json(authenticationError); + } + + const { input } = variables as { input: { siteId: string } }; + if (input.siteId === 'site-123') { + return HttpResponse.json({ data: { getClientSite: sampleSite } }); + } + return HttpResponse.json(notFoundError); + }), + + // Client Sites - List (page-based) + superopsApi.query('GetClientSiteList', ({ request, variables }) => { + if (!checkAuth(request)) { + return HttpResponse.json(authenticationError); + } + + const { input } = variables as { input: { page?: number; pageSize?: number } }; + const page = input.page ?? 1; + const pageSize = input.pageSize ?? 50; + const clientSites = page === 1 ? [sampleSite, sampleSite2] : []; + + return HttpResponse.json({ + data: { + getClientSiteList: { + clientSites, + listInfo: { page, pageSize, totalCount: 2 }, + }, + }, + }); + }), + + // Client Sites - Create + superopsApi.mutation('CreateClientSite', ({ request, variables }) => { + if (!checkAuth(request)) { + return HttpResponse.json(authenticationError); + } + + const { input } = variables as { + input: { + name: string; + timezoneCode?: string; + working24x7?: boolean; + line1?: string; + city?: string; + countryCode?: string; + contactNumber?: string; + hq?: boolean; + }; + }; + + return HttpResponse.json({ + data: { + createClientSite: { + ...sampleSite, + id: 'site-new', + name: input.name, + timezoneCode: input.timezoneCode ?? sampleSite.timezoneCode, + working24x7: input.working24x7 ?? sampleSite.working24x7, + line1: input.line1 ?? sampleSite.line1, + city: input.city ?? sampleSite.city, + countryCode: input.countryCode ?? sampleSite.countryCode, + contactNumber: input.contactNumber ?? sampleSite.contactNumber, + hq: input.hq ?? sampleSite.hq, + }, + }, + }); + }), + + // Client Sites - Update + superopsApi.mutation('UpdateClientSite', ({ request, variables }) => { + if (!checkAuth(request)) { + return HttpResponse.json(authenticationError); + } + + const { input } = variables as { + input: { + siteId: string; + name?: string; + timezoneCode?: string; + working24x7?: boolean; + line1?: string; + city?: string; + countryCode?: string; + contactNumber?: string; + hq?: boolean; + }; + }; + + if (input.siteId === 'not-found') { + return HttpResponse.json(notFoundError); + } + + return HttpResponse.json({ + data: { + updateClientSite: { + ...sampleSite, + id: input.siteId, + name: input.name ?? sampleSite.name, + timezoneCode: input.timezoneCode ?? sampleSite.timezoneCode, + working24x7: input.working24x7 ?? sampleSite.working24x7, + line1: input.line1 ?? sampleSite.line1, + city: input.city ?? sampleSite.city, + countryCode: input.countryCode ?? sampleSite.countryCode, + contactNumber: input.contactNumber ?? sampleSite.contactNumber, + hq: input.hq ?? sampleSite.hq, + }, + }, + }); + }), +]; diff --git a/tests/mocks/handlers/technicians.ts b/tests/mocks/handlers/technicians.ts new file mode 100644 index 0000000..ce894f5 --- /dev/null +++ b/tests/mocks/handlers/technicians.ts @@ -0,0 +1,179 @@ +/** + * MSW handlers for the SuperOps Technicians API. + */ + +import { HttpResponse } from 'msw'; +import { superopsApi, checkAuth, authenticationError, notFoundError, validationError } from '../shared.js'; + +/** Sample technician data. Shape matches the SuperOps `Technician` GraphQL type. */ +const sampleTechnician1 = { + userId: 'tech-123', + firstName: 'John', + lastName: 'Smith', + email: 'john.smith@acme.com', + phoneNumber: '+1-555-0123', + role: { roleId: 'role-1', name: 'Senior Technician' }, + status: 'ACTIVE', + isActive: true, + department: 'IT Support', + jobTitle: 'Senior IT Technician', + timeZone: 'America/New_York', + groups: [ + { groupId: 'group-1', name: 'Field Technicians' } + ], + createdDate: '2025-01-15T09:00:00.000Z', + modifiedDate: '2026-02-04T10:00:00.000Z', +}; + +const sampleTechnician2 = { + userId: 'tech-456', + firstName: 'Sarah', + lastName: 'Johnson', + email: 'sarah.johnson@acme.com', + phoneNumber: '+1-555-0456', + role: { roleId: 'role-2', name: 'Technician' }, + status: 'ACTIVE', + isActive: true, + department: 'IT Support', + jobTitle: 'IT Technician', + timeZone: 'America/Los_Angeles', + groups: [ + { groupId: 'group-2', name: 'Remote Support' } + ], + createdDate: '2025-03-20T14:30:00.000Z', + modifiedDate: '2026-01-10T08:15:00.000Z', +}; + +export const technicianHandlers = [ + // Technicians - List (page-based) + superopsApi.query('GetTechnicianList', ({ request, variables }) => { + if (!checkAuth(request)) { + return HttpResponse.json(authenticationError); + } + + const { input } = variables as { input: { page?: number; pageSize?: number } }; + const page = input.page ?? 1; + const pageSize = input.pageSize ?? 50; + const technicians = page === 1 ? [sampleTechnician1, sampleTechnician2] : []; + + return HttpResponse.json({ + data: { + getTechnicianList: { + technicians, + listInfo: { page, pageSize, totalCount: 2 }, + }, + }, + }); + }), + + // Technicians - Create + superopsApi.mutation('CreateTechnician', ({ request, variables }) => { + if (!checkAuth(request)) { + return HttpResponse.json(authenticationError); + } + + const { input } = variables as { + input: { + firstName: string; + lastName: string; + email: string; + phoneNumber?: string; + roleId?: string; + department?: string; + jobTitle?: string; + timeZone?: string; + groupIds?: string[]; + }; + }; + + if (!input.firstName || !input.lastName || !input.email) { + return HttpResponse.json(validationError); + } + + return HttpResponse.json({ + data: { + createTechnician: { + userId: 'tech-new', + firstName: input.firstName, + lastName: input.lastName, + email: input.email, + phoneNumber: input.phoneNumber || null, + role: input.roleId ? { roleId: input.roleId, name: 'Assigned Role' } : null, + status: 'ACTIVE', + isActive: true, + department: input.department || null, + jobTitle: input.jobTitle || null, + timeZone: input.timeZone || 'UTC', + groups: input.groupIds ? input.groupIds.map(id => ({ groupId: id, name: 'Group' })) : [], + createdDate: '2026-05-20T12:00:00.000Z', + modifiedDate: '2026-05-20T12:00:00.000Z', + }, + }, + }); + }), + + // Technicians - Update + superopsApi.mutation('UpdateTechnician', ({ request, variables }) => { + if (!checkAuth(request)) { + return HttpResponse.json(authenticationError); + } + + const { input } = variables as { + input: { + userId: string; + firstName?: string; + lastName?: string; + email?: string; + phoneNumber?: string; + roleId?: string; + department?: string; + jobTitle?: string; + timeZone?: string; + groupIds?: string[]; + isActive?: boolean; + }; + }; + + if (input.userId === 'not-found') { + return HttpResponse.json(notFoundError); + } + + return HttpResponse.json({ + data: { + updateTechnician: { + ...sampleTechnician1, + userId: input.userId, + firstName: input.firstName ?? sampleTechnician1.firstName, + lastName: input.lastName ?? sampleTechnician1.lastName, + email: input.email ?? sampleTechnician1.email, + phoneNumber: input.phoneNumber ?? sampleTechnician1.phoneNumber, + role: input.roleId ? { roleId: input.roleId, name: 'Updated Role' } : sampleTechnician1.role, + department: input.department ?? sampleTechnician1.department, + jobTitle: input.jobTitle ?? sampleTechnician1.jobTitle, + timeZone: input.timeZone ?? sampleTechnician1.timeZone, + groups: input.groupIds ? input.groupIds.map(id => ({ groupId: id, name: 'Updated Group' })) : sampleTechnician1.groups, + isActive: input.isActive ?? sampleTechnician1.isActive, + modifiedDate: '2026-05-20T12:00:00.000Z', + }, + }, + }); + }), + + // Technicians - Delete + superopsApi.mutation('DeleteTechnician', ({ request, variables }) => { + if (!checkAuth(request)) { + return HttpResponse.json(authenticationError); + } + + const { input } = variables as { input: { userId: string } }; + if (input.userId === 'not-found') { + return HttpResponse.json(notFoundError); + } + + return HttpResponse.json({ + data: { + deleteTechnician: true, + }, + }); + }), +]; diff --git a/tests/mocks/handlers/tickets.ts b/tests/mocks/handlers/tickets.ts new file mode 100644 index 0000000..60de2e5 --- /dev/null +++ b/tests/mocks/handlers/tickets.ts @@ -0,0 +1,163 @@ +/** + * MSW handlers for the SuperOps Tickets API. + */ + +import { HttpResponse } from 'msw'; +import { superopsApi, checkAuth, authenticationError, notFoundError } from '../shared.js'; + +/** Sample ticket data. Shape matches the SuperOps `Ticket` GraphQL type. */ +const sampleTicket = { + ticketId: 'ticket-123', + displayId: 'T-001', + subject: 'Network connectivity issue', + ticketType: 'INCIDENT', + requestType: 'SERVICE_REQUEST', + source: 'EMAIL', + client: { accountId: 'client-456', name: 'Acme Corp' }, + site: { id: 'site-789', name: 'Main Office' }, + requester: { userId: 'user-1', name: 'Jane Doe', email: 'jane@acme.com' }, + additionalRequester: [], + followers: {}, + techGroup: { id: 'group-1', name: 'Network Team' }, + technician: { userId: 'tech-1', name: 'John Smith', email: 'john@msp.com' }, + status: 'OPEN', + priority: 'HIGH', + impact: 'MEDIUM', + urgency: 'HIGH', + category: 'Network', + subcategory: 'Connectivity', + cause: null, + subcause: null, + resolutionCode: null, + sla: { id: 'sla-1', name: 'Standard SLA' }, + createdTime: '2026-02-04T10:00:00.000Z', + updatedTime: '2026-02-04T10:30:00.000Z', + firstResponseDueTime: '2026-02-04T14:00:00.000Z', + firstResponseTime: '2026-02-04T10:15:00.000Z', + firstResponseViolated: false, + resolutionDueTime: '2026-02-05T10:00:00.000Z', + resolutionTime: null, + resolutionViolated: false, + customFields: {}, + worklogTimespent: '30 minutes', +}; + +const sampleTicket2 = { + ticketId: 'ticket-456', + displayId: 'T-002', + subject: 'Software installation request', + ticketType: 'SERVICE_REQUEST', + requestType: 'SERVICE_REQUEST', + source: 'PORTAL', + client: { accountId: 'client-456', name: 'Acme Corp' }, + site: { id: 'site-789', name: 'Main Office' }, + requester: { userId: 'user-2', name: 'Bob Wilson', email: 'bob@acme.com' }, + additionalRequester: [], + followers: {}, + techGroup: { id: 'group-2', name: 'Desktop Team' }, + technician: null, + status: 'PENDING', + priority: 'MEDIUM', + impact: 'LOW', + urgency: 'MEDIUM', + category: 'Software', + subcategory: 'Installation', + cause: null, + subcause: null, + resolutionCode: null, + sla: { id: 'sla-1', name: 'Standard SLA' }, + createdTime: '2026-02-04T11:00:00.000Z', + updatedTime: '2026-02-04T11:00:00.000Z', + firstResponseDueTime: '2026-02-04T15:00:00.000Z', + firstResponseTime: null, + firstResponseViolated: false, + resolutionDueTime: '2026-02-06T11:00:00.000Z', + resolutionTime: null, + resolutionViolated: false, + customFields: {}, + worklogTimespent: null, +}; + +export const ticketHandlers = [ + // Tickets - Get single + superopsApi.query('GetTicket', ({ request, variables }) => { + if (!checkAuth(request)) { + return HttpResponse.json(authenticationError); + } + + const { input } = variables as { input: { ticketId: string } }; + if (input.ticketId === 'ticket-123') { + return HttpResponse.json({ data: { getTicket: sampleTicket } }); + } + return HttpResponse.json(notFoundError); + }), + + // Tickets - List (page-based) + superopsApi.query('GetTicketList', ({ request, variables }) => { + if (!checkAuth(request)) { + return HttpResponse.json(authenticationError); + } + + const { input } = variables as { input: { page?: number; pageSize?: number } }; + const page = input.page ?? 1; + const pageSize = input.pageSize ?? 50; + const tickets = page === 1 ? [sampleTicket, sampleTicket2] : []; + + return HttpResponse.json({ + data: { + getTicketList: { + tickets, + listInfo: { page, pageSize, totalCount: 2 }, + }, + }, + }); + }), + + // Tickets - Create + superopsApi.mutation('CreateTicket', ({ request, variables }) => { + if (!checkAuth(request)) { + return HttpResponse.json(authenticationError); + } + + const { input } = variables as { + input: { subject: string; [key: string]: unknown }; + }; + + return HttpResponse.json({ + data: { + createTicket: { + ...sampleTicket, + ticketId: 'ticket-new', + displayId: 'T-NEW', + subject: input.subject, + customFields: input.customFields ?? {}, + }, + }, + }); + }), + + // Tickets - Update + superopsApi.mutation('UpdateTicket', ({ request, variables }) => { + if (!checkAuth(request)) { + return HttpResponse.json(authenticationError); + } + + const { input } = variables as { + input: { ticketId: string; [key: string]: unknown }; + }; + if (input.ticketId === 'not-found') { + return HttpResponse.json(notFoundError); + } + + return HttpResponse.json({ + data: { + updateTicket: { + ...sampleTicket, + ticketId: input.ticketId, + subject: input.subject ?? sampleTicket.subject, + customFields: input.customFields ?? sampleTicket.customFields, + }, + }, + }); + }), +]; \ No newline at end of file diff --git a/tests/mocks/shared.ts b/tests/mocks/shared.ts new file mode 100644 index 0000000..2fbb9e1 --- /dev/null +++ b/tests/mocks/shared.ts @@ -0,0 +1,43 @@ +/** + * Shared MSW mocking primitives for SuperOps API tests. + */ + +import { graphql } from 'msw'; + +/** GraphQL link for the SuperOps MSP API. */ +export const superopsApi = graphql.link('https://api.superops.ai/msp'); + +/** Standard "not found" GraphQL error response. */ +export const notFoundError = { + errors: [{ message: 'Resource not found', extensions: { code: 'NOT_FOUND' } }], +}; + +/** Standard authentication-failure GraphQL error response. */ +export const authenticationError = { + errors: [{ message: 'Invalid API token', extensions: { code: 'UNAUTHENTICATED' } }], +}; + +/** Standard validation-failure GraphQL error response. */ +export const validationError = { + errors: [ + { + message: 'Name is required', + path: ['input', 'name'], + extensions: { code: 'BAD_USER_INPUT' }, + }, + ], +}; + +/** Returns true when the request carries valid SuperOps auth headers. */ +export function checkAuth(request: Request): boolean { + const authHeader = request.headers.get('Authorization'); + const subdomainHeader = request.headers.get('CustomerSubDomain'); + + return ( + authHeader !== null && + authHeader.startsWith('Bearer ') && + authHeader !== 'Bearer invalid-token' && + subdomainHeader !== null && + subdomainHeader.length > 0 + ); +} diff --git a/tests/unit/client.test.ts b/tests/unit/client.test.ts index a6395b5..1ba9f5b 100644 --- a/tests/unit/client.test.ts +++ b/tests/unit/client.test.ts @@ -44,10 +44,6 @@ describe('SuperOpsClient', () => { expect(client.contracts).toBeDefined(); expect(client.technicians).toBeDefined(); expect(client.knowledgeBase).toBeDefined(); - expect(client.runbooks).toBeDefined(); - expect(client.patches).toBeDefined(); - expect(client.remoteSessions).toBeDefined(); - expect(client.reports).toBeDefined(); }); });