From 342adea5cad7cdf4bf2e23ccdb79d2c39c5df720 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 19 Sep 2026 19:22:51 -0700 Subject: [PATCH 1/2] improvement(utils): add toArray and the scalar coercions, replacing 44 copies Ten files declared `Array.isArray(v) ? v : []` and 34 declared the `typeof v === 'x' ? v : null` one-liner under eleven different names. --- apps/sim/app/api/v2/tables/utils.ts | 18 +- .../editor/components/sub-block/utils.ts | 3 +- apps/sim/connectors/grain/grain.ts | 3 +- apps/sim/lib/internal/asana/client.ts | 3 +- .../internal/cbinsights/operations/chat.ts | 8 +- .../get-exit-probability-history.ts | 4 +- .../operations/get-org-funding-window.ts | 9 +- .../get-org-management-and-board.ts | 4 +- .../cbinsights/operations/get-org-revenue.ts | 15 +- .../operations/get-scouting-report.ts | 6 +- .../cbinsights/operations/get-strategy-map.ts | 7 +- .../operations/list-business-relationships.ts | 4 +- .../lib/internal/cbinsights/operations/rag.ts | 4 +- apps/sim/lib/internal/confluence/client.ts | 4 +- apps/sim/lib/internal/crowdstrike/client.ts | 47 +- .../lib/internal/crowdstrike/normalizers.ts | 306 ++++---- .../lib/internal/crowdstrike/operations.ts | 96 ++- apps/sim/lib/internal/gmail/client.ts | 3 +- apps/sim/lib/internal/jsm/client.ts | 3 +- apps/sim/lib/internal/vanta/normalizers.ts | 419 ++++++----- apps/sim/lib/webhooks/providers/bitbucket.ts | 35 +- apps/sim/lib/webhooks/providers/incidentio.ts | 45 +- apps/sim/lib/webhooks/providers/instantly.ts | 9 +- apps/sim/tools/azure_data_explorer/utils.ts | 15 +- .../tools/bitbucket/get_merge_task_status.ts | 11 +- apps/sim/tools/cbinsights/utils.ts | 13 +- apps/sim/tools/clickup/shared.ts | 21 +- apps/sim/tools/coda/list_doc_analytics.ts | 5 +- apps/sim/tools/dynatrace/utils.ts | 80 +-- apps/sim/tools/emailbison/utils.ts | 6 +- apps/sim/tools/harmonic/utils.ts | 9 +- apps/sim/tools/incidentio/utils.ts | 15 +- apps/sim/tools/instantly/utils.ts | 107 ++- .../mintlify/create_assistant_message.ts | 12 +- apps/sim/tools/mintlify/detect_ai_prose.ts | 16 +- .../mintlify/get_assistant_conversations.ts | 22 +- apps/sim/tools/mintlify/get_feedback.ts | 24 +- .../tools/mintlify/get_feedback_by_page.ts | 4 +- apps/sim/tools/mintlify/get_page_content.ts | 6 +- apps/sim/tools/mintlify/get_searches.ts | 10 +- apps/sim/tools/mintlify/get_update_status.ts | 34 +- apps/sim/tools/mintlify/search.ts | 6 +- apps/sim/tools/mintlify/trigger_automation.ts | 8 +- apps/sim/tools/mintlify/trigger_preview.ts | 6 +- apps/sim/tools/mintlify/trigger_update.ts | 4 +- apps/sim/tools/mintlify/utils.ts | 24 +- apps/sim/tools/rabbitmq/get_overview.ts | 17 +- apps/sim/tools/rabbitmq/utils.ts | 107 ++- apps/sim/tools/rocketlane/types.ts | 663 +++++++++--------- apps/sim/tools/smartlead/utils.ts | 6 +- apps/sim/tools/splunk/get_fired_alerts.ts | 18 +- apps/sim/tools/splunk/get_search_job.ts | 12 +- apps/sim/tools/splunk/list_apps.ts | 16 +- apps/sim/tools/splunk/list_fired_alerts.ts | 6 +- apps/sim/tools/splunk/list_indexes.ts | 18 +- apps/sim/tools/splunk/requests.test.ts | 2 +- apps/sim/tools/splunk/utils.ts | 30 +- apps/sim/tools/trello/shared.ts | 33 +- apps/sim/tools/uptimerobot/types.ts | 225 +++--- packages/utils/package.json | 4 + packages/utils/src/coerce.test.ts | 49 ++ packages/utils/src/coerce.ts | 26 + packages/utils/src/object.test.ts | 19 + packages/utils/src/object.ts | 15 + 64 files changed, 1381 insertions(+), 1398 deletions(-) create mode 100644 packages/utils/src/coerce.test.ts create mode 100644 packages/utils/src/coerce.ts diff --git a/apps/sim/app/api/v2/tables/utils.ts b/apps/sim/app/api/v2/tables/utils.ts index 117fa5bfa35..2f1e1af27ba 100644 --- a/apps/sim/app/api/v2/tables/utils.ts +++ b/apps/sim/app/api/v2/tables/utils.ts @@ -1,3 +1,4 @@ +import { toStringOrNull } from '@sim/utils/coerce' import type { V2ApiTable, V2EnrichmentProviderOutcome, @@ -238,11 +239,6 @@ function storedNumber(value: unknown): number { return typeof value === 'number' && Number.isFinite(value) ? value : 0 } -/** Reads a stored field that the published shape declares as a nullable string. */ -function storedNullableString(value: unknown): string | null { - return typeof value === 'string' ? value : null -} - /** * Reads a stored timestamp, keeping only a value the published `date-time` * format will accept. A Postgres literal or a half-written blob becomes `null` @@ -257,13 +253,13 @@ function storedTimestamp(value: unknown): string | null { function toApiEnrichmentProvider(value: unknown): V2EnrichmentProviderOutcome { const provider = (value ?? {}) as Record return { - id: storedNullableString(provider.id) ?? '', - label: storedNullableString(provider.label) ?? '', - toolId: storedNullableString(provider.toolId) ?? '', - status: storedNullableString(provider.status) ?? 'not_run', + id: toStringOrNull(provider.id) ?? '', + label: toStringOrNull(provider.label) ?? '', + toolId: toStringOrNull(provider.toolId) ?? '', + status: toStringOrNull(provider.status) ?? 'not_run', cost: storedNumber(provider.cost), durationMs: storedNumber(provider.durationMs), - error: storedNullableString(provider.error), + error: toStringOrNull(provider.error), } } @@ -287,7 +283,7 @@ export function toApiEnrichmentDetail( completedAt: storedTimestamp(stored.completedAt), durationMs: storedNumber(stored.durationMs), totalCost: storedNumber(stored.totalCost), - matchedProvider: storedNullableString(stored.matchedProvider), + matchedProvider: toStringOrNull(stored.matchedProvider), aborted: stored.aborted === true, providers: Array.isArray(stored.providers) ? stored.providers.map(toApiEnrichmentProvider) : [], } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils.ts index d5d513a7b3c..7c3123b8b11 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/utils.ts @@ -1,3 +1,4 @@ +import { toArray } from '@sim/utils/object' /** * Extracts the raw value from a preview context entry. * @@ -41,5 +42,5 @@ export function parseJsonArrayValue(value: unknown): T[] { return [] } } - return Array.isArray(parsed) ? (parsed as T[]) : [] + return toArray(parsed) } diff --git a/apps/sim/connectors/grain/grain.ts b/apps/sim/connectors/grain/grain.ts index 54cc21ccf27..45adc06f476 100644 --- a/apps/sim/connectors/grain/grain.ts +++ b/apps/sim/connectors/grain/grain.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { toArray } from '@sim/utils/object' import { fetchWithRetry } from '@/lib/knowledge/documents/secure-fetch.server' import { VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils' import { grainConnectorMeta } from '@/connectors/grain/meta' @@ -328,7 +329,7 @@ async function fetchTranscript( } const data = await response.json() - return Array.isArray(data) ? (data as GrainTranscriptSegment[]) : [] + return toArray(data) } export const grainConnector: ConnectorConfig = { diff --git a/apps/sim/lib/internal/asana/client.ts b/apps/sim/lib/internal/asana/client.ts index 1ea4e1b025d..2a5f70fde22 100644 --- a/apps/sim/lib/internal/asana/client.ts +++ b/apps/sim/lib/internal/asana/client.ts @@ -1,3 +1,4 @@ +import { toArray } from '@sim/utils/object' import { readResponseTextWithLimit } from '@/lib/core/utils/stream-limits' import { AsanaOperationError } from '@/lib/internal/asana/errors' @@ -13,7 +14,7 @@ export function asObject(value: unknown): AsanaJsonObject { } export function asArray(value: unknown): unknown[] { - return Array.isArray(value) ? value : [] + return toArray(value) } function providerErrorMessage(response: Response, text: string): string { diff --git a/apps/sim/lib/internal/cbinsights/operations/chat.ts b/apps/sim/lib/internal/cbinsights/operations/chat.ts index dcb050d7ae8..0d6444a23ed 100644 --- a/apps/sim/lib/internal/cbinsights/operations/chat.ts +++ b/apps/sim/lib/internal/cbinsights/operations/chat.ts @@ -1,8 +1,8 @@ +import { toStringOrNull } from '@sim/utils/coerce' import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' import type { CbInsightsChatParams } from '@/tools/cbinsights/chat' import { asArray, - asString, asStringArray, cbInsightsRequest, compactBody, @@ -29,9 +29,9 @@ export const executeCbinsightsChatOperation: InternalToolOperationImplementation body: compactBody({ message, chatID: parseOptionalStringParam(params.chatId, 'chatId') }), }, (data) => ({ - chatId: asString(data.chatID), - title: asString(data.title), - message: asString(data.message), + chatId: toStringOrNull(data.chatID), + title: toStringOrNull(data.title), + message: toStringOrNull(data.message), sources: asArray(data.sources), relatedContent: asArray(data.relatedContent), suggestions: asStringArray(data.suggestions), diff --git a/apps/sim/lib/internal/cbinsights/operations/get-exit-probability-history.ts b/apps/sim/lib/internal/cbinsights/operations/get-exit-probability-history.ts index f439356d450..41d260dfc2e 100644 --- a/apps/sim/lib/internal/cbinsights/operations/get-exit-probability-history.ts +++ b/apps/sim/lib/internal/cbinsights/operations/get-exit-probability-history.ts @@ -1,8 +1,8 @@ +import { toStringOrNull } from '@sim/utils/coerce' import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' import type { CbInsightsExitProbabilityHistoryParams } from '@/tools/cbinsights/get_exit_probability_history' import { asArray, - asString, cbInsightsRequest, compactBody, parseOptionalStringParam, @@ -25,7 +25,7 @@ export const executeCbinsightsGetExitProbabilityHistoryOperation: InternalToolOp (data) => ({ ipo: asArray(data.ipo), mna: asArray(data.mna), - incompleteRoundType: asString(data.incompleteRoundType), + incompleteRoundType: toStringOrNull(data.incompleteRoundType), }), signal ) diff --git a/apps/sim/lib/internal/cbinsights/operations/get-org-funding-window.ts b/apps/sim/lib/internal/cbinsights/operations/get-org-funding-window.ts index 2b23c569336..0cb0ef70bf5 100644 --- a/apps/sim/lib/internal/cbinsights/operations/get-org-funding-window.ts +++ b/apps/sim/lib/internal/cbinsights/operations/get-org-funding-window.ts @@ -1,7 +1,8 @@ +import { toNumberOrNull, toStringOrNull } from '@sim/utils/coerce' import { toRecordOrNull } from '@sim/utils/object' import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' import type { CbInsightsOrgParams } from '@/tools/cbinsights/types' -import { asNumber, asString, cbInsightsRequest, requireOrgId } from '@/tools/cbinsights/utils' +import { cbInsightsRequest, requireOrgId } from '@/tools/cbinsights/utils' export const executeCbinsightsGetOrgFundingWindowOperation: InternalToolOperationImplementation< CbInsightsOrgParams @@ -17,9 +18,9 @@ export const executeCbinsightsGetOrgFundingWindowOperation: InternalToolOperatio params, { path: `/v2/organizations/${orgId}/fundingwindow` }, (data) => ({ - windowStart: asString(data.windowStart), - windowEnd: asString(data.windowEnd), - cohortNextRoundRate: asNumber(data.cohortNextRoundRate), + windowStart: toStringOrNull(data.windowStart), + windowEnd: toStringOrNull(data.windowEnd), + cohortNextRoundRate: toNumberOrNull(data.cohortNextRoundRate), cohortCriteria: toRecordOrNull(data.cohortCriteria), latestFunding: toRecordOrNull(data.latestFunding), }), diff --git a/apps/sim/lib/internal/cbinsights/operations/get-org-management-and-board.ts b/apps/sim/lib/internal/cbinsights/operations/get-org-management-and-board.ts index cfb7dc0dd54..7473ff67ab3 100644 --- a/apps/sim/lib/internal/cbinsights/operations/get-org-management-and-board.ts +++ b/apps/sim/lib/internal/cbinsights/operations/get-org-management-and-board.ts @@ -1,8 +1,8 @@ +import { toNumberOrNull } from '@sim/utils/coerce' import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' import type { CbInsightsOrgManagementParams } from '@/tools/cbinsights/get_org_management_and_board' import { asArray, - asNumber, cbInsightsRequest, compactBody, parseIdListParam, @@ -21,7 +21,7 @@ export const executeCbinsightsGetOrgManagementAndBoardOperation: InternalToolOpe }, (data) => ({ people: asArray(data.people), - mosaicManagement: asNumber(data.mosaicManagement), + mosaicManagement: toNumberOrNull(data.mosaicManagement), }), signal ) diff --git a/apps/sim/lib/internal/cbinsights/operations/get-org-revenue.ts b/apps/sim/lib/internal/cbinsights/operations/get-org-revenue.ts index e7760d221ef..a6d381a720d 100644 --- a/apps/sim/lib/internal/cbinsights/operations/get-org-revenue.ts +++ b/apps/sim/lib/internal/cbinsights/operations/get-org-revenue.ts @@ -1,12 +1,7 @@ +import { toNumberOrNull, toStringOrNull } from '@sim/utils/coerce' import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' import type { CbInsightsOrgParams } from '@/tools/cbinsights/types' -import { - asArray, - asNumber, - asString, - cbInsightsRequest, - requireOrgId, -} from '@/tools/cbinsights/utils' +import { asArray, cbInsightsRequest, requireOrgId } from '@/tools/cbinsights/utils' export const executeCbinsightsGetOrgRevenueOperation: InternalToolOperationImplementation< CbInsightsOrgParams @@ -21,9 +16,9 @@ export const executeCbinsightsGetOrgRevenueOperation: InternalToolOperationImple params, { path: `/v2/organizations/${orgId}/revenuebyyear` }, (data) => ({ - orgId: asNumber(data.orgId), - orgName: asString(data.orgName), - orgUrl: asString(data.orgUrl), + orgId: toNumberOrNull(data.orgId), + orgName: toStringOrNull(data.orgName), + orgUrl: toStringOrNull(data.orgUrl), revenue: asArray(data.revenue), }), signal diff --git a/apps/sim/lib/internal/cbinsights/operations/get-scouting-report.ts b/apps/sim/lib/internal/cbinsights/operations/get-scouting-report.ts index 8559da31071..0691c0baac2 100644 --- a/apps/sim/lib/internal/cbinsights/operations/get-scouting-report.ts +++ b/apps/sim/lib/internal/cbinsights/operations/get-scouting-report.ts @@ -1,8 +1,8 @@ +import { toStringOrNull } from '@sim/utils/coerce' import { toRecordOrNull } from '@sim/utils/object' import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' import type { CbInsightsOrgParams } from '@/tools/cbinsights/types' import { - asString, cbInsightsRequest, requireOrgId, SCOUTING_REPORT_TIMEOUT_MS, @@ -24,8 +24,8 @@ export const executeCbinsightsGetScoutingReportOperation: InternalToolOperationI }, (data) => ({ orgInfo: toRecordOrNull(data.orgInfo), - reportMarkdown: asString(data.reportMarkdown), - reportJson: asString(data.reportJson), + reportMarkdown: toStringOrNull(data.reportMarkdown), + reportJson: toStringOrNull(data.reportJson), }), signal ) diff --git a/apps/sim/lib/internal/cbinsights/operations/get-strategy-map.ts b/apps/sim/lib/internal/cbinsights/operations/get-strategy-map.ts index 474b859573a..8beec8c9358 100644 --- a/apps/sim/lib/internal/cbinsights/operations/get-strategy-map.ts +++ b/apps/sim/lib/internal/cbinsights/operations/get-strategy-map.ts @@ -1,6 +1,7 @@ +import { toStringOrNull } from '@sim/utils/coerce' import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' import type { CbInsightsOrgParams } from '@/tools/cbinsights/types' -import { asArray, asString, cbInsightsRequest, requireOrgId } from '@/tools/cbinsights/utils' +import { asArray, cbInsightsRequest, requireOrgId } from '@/tools/cbinsights/utils' export const executeCbinsightsGetStrategyMapOperation: InternalToolOperationImplementation< CbInsightsOrgParams @@ -10,8 +11,8 @@ export const executeCbinsightsGetStrategyMapOperation: InternalToolOperationImpl params, { path: `/v2/organizations/${orgId}/strategymap` }, (data) => ({ - orgName: asString(data.orgName), - logoUrl: asString(data.logoUrl), + orgName: toStringOrNull(data.orgName), + logoUrl: toStringOrNull(data.logoUrl), categories: asArray(data.categories), }), signal diff --git a/apps/sim/lib/internal/cbinsights/operations/list-business-relationships.ts b/apps/sim/lib/internal/cbinsights/operations/list-business-relationships.ts index ece9747b27c..0e335f58e39 100644 --- a/apps/sim/lib/internal/cbinsights/operations/list-business-relationships.ts +++ b/apps/sim/lib/internal/cbinsights/operations/list-business-relationships.ts @@ -1,8 +1,8 @@ +import { toStringOrNull } from '@sim/utils/coerce' import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' import type { CbInsightsListBusinessRelationshipsParams } from '@/tools/cbinsights/list_business_relationships' import { asArray, - asString, cbInsightsRequest, compactBody, parseOptionalStringParam, @@ -21,6 +21,6 @@ export const executeCbinsightsListBusinessRelationshipsOperation: InternalToolOp nextPageToken: parseOptionalStringParam(params.nextPageToken, 'nextPageToken'), }), }, - (data) => ({ orgs: asArray(data.orgs), nextPageToken: asString(data.nextPageToken) }), + (data) => ({ orgs: asArray(data.orgs), nextPageToken: toStringOrNull(data.nextPageToken) }), signal ) diff --git a/apps/sim/lib/internal/cbinsights/operations/rag.ts b/apps/sim/lib/internal/cbinsights/operations/rag.ts index e4a9f3ab64e..5781f602324 100644 --- a/apps/sim/lib/internal/cbinsights/operations/rag.ts +++ b/apps/sim/lib/internal/cbinsights/operations/rag.ts @@ -1,7 +1,7 @@ +import { toStringOrNull } from '@sim/utils/coerce' import type { InternalToolOperationImplementation } from '@/lib/internal/tool-operations/types' import type { CbInsightsRagParams } from '@/tools/cbinsights/rag' import { - asString, asStringArray, cbInsightsRequest, parseOptionalStringParam, @@ -19,7 +19,7 @@ export const executeCbinsightsRagOperation: InternalToolOperationImplementation< return cbInsightsRequest<{ data?: unknown; guidance?: unknown }>( params, { path: '/v2/cbirag', body: { message } }, - (data) => ({ data: asString(data.data), guidance: asStringArray(data.guidance) }), + (data) => ({ data: toStringOrNull(data.data), guidance: asStringArray(data.guidance) }), signal ) } diff --git a/apps/sim/lib/internal/confluence/client.ts b/apps/sim/lib/internal/confluence/client.ts index 54670de71aa..fae557a5d88 100644 --- a/apps/sim/lib/internal/confluence/client.ts +++ b/apps/sim/lib/internal/confluence/client.ts @@ -1,4 +1,4 @@ -import { toRecord } from '@sim/utils/object' +import { toArray, toRecord } from '@sim/utils/object' import { validateJiraCloudId } from '@/lib/core/security/input-validation' import { MAX_JSON_API_RESPONSE_BYTES } from '@/lib/core/security/input-validation.server' import { @@ -22,7 +22,7 @@ export function asObject(value: unknown): JsonObject { } export function asArray(value: unknown): unknown[] { - return Array.isArray(value) ? value : [] + return toArray(value) } export function nested(object: JsonObject, ...keys: string[]): unknown { diff --git a/apps/sim/lib/internal/crowdstrike/client.ts b/apps/sim/lib/internal/crowdstrike/client.ts index f5198ac245b..1c4ed72526f 100644 --- a/apps/sim/lib/internal/crowdstrike/client.ts +++ b/apps/sim/lib/internal/crowdstrike/client.ts @@ -1,3 +1,4 @@ +import { toNumberOrNull, toStringOrNull } from '@sim/utils/coerce' import { isRecordLike, toRecordOrNull } from '@sim/utils/object' import { MAX_JSON_API_RESPONSE_BYTES } from '@/lib/core/security/input-validation.server' import { readResponseTextWithLimit } from '@/lib/core/utils/stream-limits' @@ -18,18 +19,6 @@ export function getCloudBaseUrl(cloud: CrowdStrikeCloud): string { return CLOUD_BASE_URLS[cloud] } -export function getString(value: unknown): string | null { - return typeof value === 'string' ? value : null -} - -export function getNumber(value: unknown): number | null { - return typeof value === 'number' ? value : null -} - -export function getBoolean(value: unknown): boolean | null { - return typeof value === 'boolean' ? value : null -} - export function getStringArray(value: unknown): string[] { if (!Array.isArray(value)) { return [] @@ -83,9 +72,9 @@ export function getPagination(data: unknown) { const { pagination } = data.meta return { - limit: getNumber(pagination.limit), - offset: getNumber(pagination.offset), - total: getNumber(pagination.total), + limit: toNumberOrNull(pagination.limit), + offset: toNumberOrNull(pagination.offset), + total: toNumberOrNull(pagination.total), } } @@ -98,10 +87,10 @@ export function getCursorPagination(data: unknown) { const { pagination } = data.meta return { - after: getString(pagination.after), - limit: getNumber(pagination.limit), - offset: getNumber(pagination.offset), - total: getNumber(pagination.total), + after: toStringOrNull(pagination.after), + limit: toNumberOrNull(pagination.limit), + offset: toNumberOrNull(pagination.offset), + total: toNumberOrNull(pagination.total), } } @@ -114,9 +103,9 @@ export function getSpotlightPagination(data: unknown) { const { pagination } = data.meta return { - after: getString(pagination.after), - limit: getNumber(pagination.limit), - total: getNumber(pagination.total), + after: toStringOrNull(pagination.after), + limit: toNumberOrNull(pagination.limit), + total: toNumberOrNull(pagination.total), } } @@ -130,9 +119,9 @@ export function getEnvelopeErrors(data: unknown) { } return getRecordArray(data.errors).map((entry) => ({ - code: getNumber(entry.code), - id: getString(entry.id), - message: getString(entry.message), + code: toNumberOrNull(entry.code), + id: toStringOrNull(entry.id), + message: toStringOrNull(entry.message), })) } @@ -144,16 +133,16 @@ export function getFalconErrorMessage(data: unknown, fallback: string): string { const errors = Array.isArray(data.errors) ? data.errors : [] const firstError = errors[0] if (isRecordLike(firstError)) { - const firstMessage = getString(firstError.message) ?? getString(firstError.code) + const firstMessage = toStringOrNull(firstError.message) ?? toStringOrNull(firstError.code) if (firstMessage) { return firstMessage } } return ( - getString(data.message) ?? - getString(data.error_description) ?? - getString(data.error) ?? + toStringOrNull(data.message) ?? + toStringOrNull(data.error_description) ?? + toStringOrNull(data.error) ?? fallback ) } diff --git a/apps/sim/lib/internal/crowdstrike/normalizers.ts b/apps/sim/lib/internal/crowdstrike/normalizers.ts index ac6c6d03dd4..f6acca51496 100644 --- a/apps/sim/lib/internal/crowdstrike/normalizers.ts +++ b/apps/sim/lib/internal/crowdstrike/normalizers.ts @@ -1,9 +1,7 @@ +import { toBooleanOrNull, toNumberOrNull, toStringOrNull } from '@sim/utils/coerce' import { - getBoolean, - getNumber, getRecord, getRecordArray, - getString, getStringArray, type JsonRecord, } from '@/lib/internal/crowdstrike/client' @@ -21,80 +19,80 @@ export function normalizeAlert(resource: JsonRecord): CrowdStrikeAlert { const device = getRecord(resource.device) return { - compositeId: getString(resource.composite_id), - id: getString(resource.id), - cid: getString(resource.cid), - aggregateId: getString(resource.aggregate_id), - agentId: getString(resource.agent_id), - deviceId: device ? getString(device.device_id) : null, - hostname: device ? getString(device.hostname) : null, - name: getString(resource.name), - displayName: getString(resource.display_name), - description: getString(resource.description), - type: getString(resource.type), - product: getString(resource.product), - platform: getString(resource.platform), - severity: getNumber(resource.severity), - severityName: getString(resource.severity_name), - confidence: getNumber(resource.confidence), - status: getString(resource.status), - assignedToName: getString(resource.assigned_to_name), - assignedToUid: getString(resource.assigned_to_uid), - assignedToUuid: getString(resource.assigned_to_uuid), - tactic: getString(resource.tactic), - tacticId: getString(resource.tactic_id), - technique: getString(resource.technique), - techniqueId: getString(resource.technique_id), - scenario: getString(resource.scenario), - objective: getString(resource.objective), - resolution: getString(resource.resolution), - showInUi: getBoolean(resource.show_in_ui), + compositeId: toStringOrNull(resource.composite_id), + id: toStringOrNull(resource.id), + cid: toStringOrNull(resource.cid), + aggregateId: toStringOrNull(resource.aggregate_id), + agentId: toStringOrNull(resource.agent_id), + deviceId: device ? toStringOrNull(device.device_id) : null, + hostname: device ? toStringOrNull(device.hostname) : null, + name: toStringOrNull(resource.name), + displayName: toStringOrNull(resource.display_name), + description: toStringOrNull(resource.description), + type: toStringOrNull(resource.type), + product: toStringOrNull(resource.product), + platform: toStringOrNull(resource.platform), + severity: toNumberOrNull(resource.severity), + severityName: toStringOrNull(resource.severity_name), + confidence: toNumberOrNull(resource.confidence), + status: toStringOrNull(resource.status), + assignedToName: toStringOrNull(resource.assigned_to_name), + assignedToUid: toStringOrNull(resource.assigned_to_uid), + assignedToUuid: toStringOrNull(resource.assigned_to_uuid), + tactic: toStringOrNull(resource.tactic), + tacticId: toStringOrNull(resource.tactic_id), + technique: toStringOrNull(resource.technique), + techniqueId: toStringOrNull(resource.technique_id), + scenario: toStringOrNull(resource.scenario), + objective: toStringOrNull(resource.objective), + resolution: toStringOrNull(resource.resolution), + showInUi: toBooleanOrNull(resource.show_in_ui), tags: getStringArray(resource.tags), - filename: getString(resource.filename), - filepath: getString(resource.filepath), - cmdline: getString(resource.cmdline), - sha256: getString(resource.sha256), - sha1: getString(resource.sha1), - md5: getString(resource.md5), - userName: getString(resource.user_name), - userId: getString(resource.user_id), - patternId: getNumber(resource.pattern_id), - falconHostLink: getString(resource.falcon_host_link), - controlGraphId: getString(resource.control_graph_id), - external: getBoolean(resource.external), - emailSent: getBoolean(resource.email_sent), - isAggregated: getBoolean(resource.is_aggregated), - isFalconPlatformIoa: getBoolean(resource.is_falcon_platform_ioa), + filename: toStringOrNull(resource.filename), + filepath: toStringOrNull(resource.filepath), + cmdline: toStringOrNull(resource.cmdline), + sha256: toStringOrNull(resource.sha256), + sha1: toStringOrNull(resource.sha1), + md5: toStringOrNull(resource.md5), + userName: toStringOrNull(resource.user_name), + userId: toStringOrNull(resource.user_id), + patternId: toNumberOrNull(resource.pattern_id), + falconHostLink: toStringOrNull(resource.falcon_host_link), + controlGraphId: toStringOrNull(resource.control_graph_id), + external: toBooleanOrNull(resource.external), + emailSent: toBooleanOrNull(resource.email_sent), + isAggregated: toBooleanOrNull(resource.is_aggregated), + isFalconPlatformIoa: toBooleanOrNull(resource.is_falcon_platform_ioa), dataDomains: getStringArray(resource.data_domains), iocValues: getStringArray(resource.ioc_values), linkedCaseIds: getStringArray(resource.linked_case_ids), linkedBehavioralDetections: getStringArray(resource.linked_behavioral_detections), - timestamp: getString(resource.timestamp), - createdTimestamp: getString(resource.created_timestamp), - updatedTimestamp: getString(resource.updated_timestamp), - crawledTimestamp: getString(resource.crawled_timestamp), - contextTimestamp: getString(resource.context_timestamp), + timestamp: toStringOrNull(resource.timestamp), + createdTimestamp: toStringOrNull(resource.created_timestamp), + updatedTimestamp: toStringOrNull(resource.updated_timestamp), + crawledTimestamp: toStringOrNull(resource.crawled_timestamp), + contextTimestamp: toStringOrNull(resource.context_timestamp), } } export function normalizeAffectedEntity(resource: JsonRecord): CrowdStrikeAffectedEntity { return { - id: getString(resource.id), - path: getString(resource.path), + id: toStringOrNull(resource.id), + path: toStringOrNull(resource.path), } } export function normalizeHostGroup(resource: JsonRecord): CrowdStrikeHostGroup { return { - id: getString(resource.id), - name: getString(resource.name), - description: getString(resource.description), - groupType: getString(resource.group_type), - assignmentRule: getString(resource.assignment_rule), - createdBy: getString(resource.created_by), - createdTimestamp: getString(resource.created_timestamp), - modifiedBy: getString(resource.modified_by), - modifiedTimestamp: getString(resource.modified_timestamp), + id: toStringOrNull(resource.id), + name: toStringOrNull(resource.name), + description: toStringOrNull(resource.description), + groupType: toStringOrNull(resource.group_type), + assignmentRule: toStringOrNull(resource.assignment_rule), + createdBy: toStringOrNull(resource.created_by), + createdTimestamp: toStringOrNull(resource.created_timestamp), + modifiedBy: toStringOrNull(resource.modified_by), + modifiedTimestamp: toStringOrNull(resource.modified_timestamp), } } @@ -102,38 +100,38 @@ export function normalizeIndicator(resource: JsonRecord): CrowdStrikeIndicator { const metadata = getRecord(resource.metadata) return { - id: getString(resource.id), - type: getString(resource.type), - value: getString(resource.value), - action: getString(resource.action), - mobileAction: getString(resource.mobile_action), - severity: getString(resource.severity), - description: getString(resource.description), - source: getString(resource.source), - appliedGlobally: getBoolean(resource.applied_globally), + id: toStringOrNull(resource.id), + type: toStringOrNull(resource.type), + value: toStringOrNull(resource.value), + action: toStringOrNull(resource.action), + mobileAction: toStringOrNull(resource.mobile_action), + severity: toStringOrNull(resource.severity), + description: toStringOrNull(resource.description), + source: toStringOrNull(resource.source), + appliedGlobally: toBooleanOrNull(resource.applied_globally), platforms: getStringArray(resource.platforms), hostGroups: getStringArray(resource.host_groups), tags: getStringArray(resource.tags), - expiration: getString(resource.expiration), - expired: getBoolean(resource.expired), - deleted: getBoolean(resource.deleted), - fromParent: getBoolean(resource.from_parent), - parentCidName: getString(resource.parent_cid_name), - createdBy: getString(resource.created_by), - createdOn: getString(resource.created_on), - modifiedBy: getString(resource.modified_by), - modifiedOn: getString(resource.modified_on), + expiration: toStringOrNull(resource.expiration), + expired: toBooleanOrNull(resource.expired), + deleted: toBooleanOrNull(resource.deleted), + fromParent: toBooleanOrNull(resource.from_parent), + parentCidName: toStringOrNull(resource.parent_cid_name), + createdBy: toStringOrNull(resource.created_by), + createdOn: toStringOrNull(resource.created_on), + modifiedBy: toStringOrNull(resource.modified_by), + modifiedOn: toStringOrNull(resource.modified_on), metadata: metadata ? { - avHits: getNumber(metadata.av_hits), - companyName: getString(metadata.company_name), - fileDescription: getString(metadata.file_description), - fileVersion: getString(metadata.file_version), - filename: getString(metadata.filename), - originalFilename: getString(metadata.original_filename), - productName: getString(metadata.product_name), - productVersion: getString(metadata.product_version), - signed: getBoolean(metadata.signed), + avHits: toNumberOrNull(metadata.av_hits), + companyName: toStringOrNull(metadata.company_name), + fileDescription: toStringOrNull(metadata.file_description), + fileVersion: toStringOrNull(metadata.file_version), + filename: toStringOrNull(metadata.filename), + originalFilename: toStringOrNull(metadata.original_filename), + productName: toStringOrNull(metadata.product_name), + productVersion: toStringOrNull(metadata.product_version), + signed: toBooleanOrNull(metadata.signed), } : null, } @@ -148,72 +146,72 @@ export function normalizeVulnerability(resource: JsonRecord): CrowdStrikeVulnera const suppressionInfo = getRecord(resource.suppression_info) return { - id: getString(resource.id), - aid: getString(resource.aid), - cid: getString(resource.cid), - status: getString(resource.status), - confidence: getString(resource.confidence), - vulnerabilityId: getString(resource.vulnerability_id), - createdTimestamp: getString(resource.created_timestamp), - updatedTimestamp: getString(resource.updated_timestamp), - closedTimestamp: getString(resource.closed_timestamp), + id: toStringOrNull(resource.id), + aid: toStringOrNull(resource.aid), + cid: toStringOrNull(resource.cid), + status: toStringOrNull(resource.status), + confidence: toStringOrNull(resource.confidence), + vulnerabilityId: toStringOrNull(resource.vulnerability_id), + createdTimestamp: toStringOrNull(resource.created_timestamp), + updatedTimestamp: toStringOrNull(resource.updated_timestamp), + closedTimestamp: toStringOrNull(resource.closed_timestamp), cve: cve ? { - id: getString(cve.id), - baseScore: getNumber(cve.base_score), - severity: getString(cve.severity), - exprtRating: getString(cve.exprt_rating), - exploitStatus: getNumber(cve.exploit_status), - exploitabilityScore: getNumber(cve.exploitability_score), - impactScore: getNumber(cve.impact_score), - remediationLevel: getString(cve.remediation_level), - description: getString(cve.description), - publishedDate: getString(cve.published_date), - vector: getString(cve.vector), + id: toStringOrNull(cve.id), + baseScore: toNumberOrNull(cve.base_score), + severity: toStringOrNull(cve.severity), + exprtRating: toStringOrNull(cve.exprt_rating), + exploitStatus: toNumberOrNull(cve.exploit_status), + exploitabilityScore: toNumberOrNull(cve.exploitability_score), + impactScore: toNumberOrNull(cve.impact_score), + remediationLevel: toStringOrNull(cve.remediation_level), + description: toStringOrNull(cve.description), + publishedDate: toStringOrNull(cve.published_date), + vector: toStringOrNull(cve.vector), types: getStringArray(cve.types), - isCisaKev: cisaInfo ? getBoolean(cisaInfo.is_cisa_kev) : null, - cisaDueDate: cisaInfo ? getString(cisaInfo.due_date) : null, + isCisaKev: cisaInfo ? toBooleanOrNull(cisaInfo.is_cisa_kev) : null, + cisaDueDate: cisaInfo ? toStringOrNull(cisaInfo.due_date) : null, } : null, app: app ? { - productNameNormalized: getString(app.product_name_normalized), - productNameVersion: getString(app.product_name_version), - vendorNormalized: getString(app.vendor_normalized), + productNameNormalized: toStringOrNull(app.product_name_normalized), + productNameVersion: toStringOrNull(app.product_name_version), + vendorNormalized: toStringOrNull(app.vendor_normalized), } : null, hostInfo: hostInfo ? { - hostname: getString(hostInfo.hostname), - localIp: getString(hostInfo.local_ip), - machineDomain: getString(hostInfo.machine_domain), - osVersion: getString(hostInfo.os_version), - platform: getString(hostInfo.platform), - productTypeDesc: getString(hostInfo.product_type_desc), - assetCriticality: getString(hostInfo.asset_criticality), - internetExposure: getString(hostInfo.internet_exposure), + hostname: toStringOrNull(hostInfo.hostname), + localIp: toStringOrNull(hostInfo.local_ip), + machineDomain: toStringOrNull(hostInfo.machine_domain), + osVersion: toStringOrNull(hostInfo.os_version), + platform: toStringOrNull(hostInfo.platform), + productTypeDesc: toStringOrNull(hostInfo.product_type_desc), + assetCriticality: toStringOrNull(hostInfo.asset_criticality), + internetExposure: toStringOrNull(hostInfo.internet_exposure), tags: getStringArray(hostInfo.tags), groups: getRecordArray(hostInfo.groups) - .map((group) => getString(group.name)) + .map((group) => toStringOrNull(group.name)) .filter((name): name is string => name !== null), } : null, remediationIds: remediation ? getStringArray(remediation.ids) : [], remediations: remediation ? getRecordArray(remediation.entities).map((entity) => ({ - id: getString(entity.id), - title: getString(entity.title), - action: getString(entity.action), - type: getString(entity.type), - link: getString(entity.link), - reference: getString(entity.reference), - vendorUrl: getString(entity.vendor_url), + id: toStringOrNull(entity.id), + title: toStringOrNull(entity.title), + action: toStringOrNull(entity.action), + type: toStringOrNull(entity.type), + link: toStringOrNull(entity.link), + reference: toStringOrNull(entity.reference), + vendorUrl: toStringOrNull(entity.vendor_url), })) : [], suppressionInfo: suppressionInfo ? { - isSuppressed: getBoolean(suppressionInfo.is_suppressed), - reason: getString(suppressionInfo.reason), + isSuppressed: toBooleanOrNull(suppressionInfo.is_suppressed), + reason: toStringOrNull(suppressionInfo.reason), } : null, } @@ -226,9 +224,9 @@ function normalizeFalconUser(value: unknown): CrowdStrikeFalconUser | null { } return { - uuid: getString(user.uuid), - email: getString(user.email), - fullName: getString(user.full_name), + uuid: toStringOrNull(user.uuid), + email: toStringOrNull(user.email), + fullName: toStringOrNull(user.full_name), } } @@ -239,28 +237,28 @@ export function normalizeCase(resource: JsonRecord): CrowdStrikeCase { const readOnly = getRecord(resource.read_only) return { - id: getString(resource.id), - cid: getString(resource.cid), - name: getString(resource.name), - description: getString(resource.description), - descriptionFormat: getString(resource.description_format), - status: getString(resource.status), - severity: getNumber(resource.severity), - severityLevel: severityInfo ? getString(severityInfo.level) : null, - referenceId: getString(resource.reference_id), - version: getNumber(resource.version), + id: toStringOrNull(resource.id), + cid: toStringOrNull(resource.cid), + name: toStringOrNull(resource.name), + description: toStringOrNull(resource.description), + descriptionFormat: toStringOrNull(resource.description_format), + status: toStringOrNull(resource.status), + severity: toNumberOrNull(resource.severity), + severityLevel: severityInfo ? toStringOrNull(severityInfo.level) : null, + referenceId: toStringOrNull(resource.reference_id), + version: toNumberOrNull(resource.version), tags: getStringArray(resource.tags), assignedTo: normalizeFalconUser(resource.assigned_to), createdBy: normalizeFalconUser(resource.created_by), lastUpdatedBy: normalizeFalconUser(resource.last_updated_by), - createdTimestamp: getString(resource.created_timestamp), - updatedTimestamp: getString(resource.updated_timestamp), - startTimestamp: getString(resource.start_timestamp), - endTimestamp: getString(resource.end_timestamp), - templateId: template ? getString(template.id) : null, - templateName: template ? getString(template.name) : null, - slaId: sla ? getString(sla.id) : null, - slaName: sla ? getString(sla.name) : null, - isReadOnly: readOnly ? getBoolean(readOnly.is_read_only) : null, + createdTimestamp: toStringOrNull(resource.created_timestamp), + updatedTimestamp: toStringOrNull(resource.updated_timestamp), + startTimestamp: toStringOrNull(resource.start_timestamp), + endTimestamp: toStringOrNull(resource.end_timestamp), + templateId: template ? toStringOrNull(template.id) : null, + templateName: template ? toStringOrNull(template.name) : null, + slaId: sla ? toStringOrNull(sla.id) : null, + slaName: sla ? toStringOrNull(sla.name) : null, + isReadOnly: readOnly ? toBooleanOrNull(readOnly.is_read_only) : null, } } diff --git a/apps/sim/lib/internal/crowdstrike/operations.ts b/apps/sim/lib/internal/crowdstrike/operations.ts index a753bea5d67..d45d2d1f792 100644 --- a/apps/sim/lib/internal/crowdstrike/operations.ts +++ b/apps/sim/lib/internal/crowdstrike/operations.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { toBooleanOrNull, toNumberOrNull, toStringOrNull } from '@sim/utils/coerce' import { isRecordLike, toRecord } from '@sim/utils/object' import { truncate } from '@sim/utils/string' import type { CrowdstrikeQueryBody } from '@/lib/api/contracts/tools/crowdstrike' @@ -7,19 +8,16 @@ import { type CrowdStrikeCallResult, callCrowdStrike, getAccessToken, - getBoolean, getCloudBaseUrl, getCursorPagination, getEnvelopeErrors, getFalconErrorMessage, getFirstRecordResource, - getNumber, getPagination, getRecordArray, getRecordResources, getResourcesArray, getSpotlightPagination, - getString, getStringArray, getStringResources, } from '@/lib/internal/crowdstrike/client' @@ -103,25 +101,25 @@ export function failedWithoutResources( function normalizeSensor(resource: Record) { return { - agentVersion: getString(resource.agent_version), - cid: getString(resource.cid), - deviceId: getString(resource.device_id), - heartbeatTime: getNumber(resource.heartbeat_time), - hostname: getString(resource.hostname), - idpPolicyId: getString(resource.idp_policy_id), - idpPolicyName: getString(resource.idp_policy_name), - ipAddress: getString(resource.local_ip), - kerberosConfig: getString(resource.kerberos_config), - ldapConfig: getString(resource.ldap_config), - ldapsConfig: getString(resource.ldaps_config), - machineDomain: getString(resource.machine_domain), - ntlmConfig: getString(resource.ntlm_config), - osVersion: getString(resource.os_version), - rdpToDcConfig: getString(resource.rdp_to_dc_config), - smbToDcConfig: getString(resource.smb_to_dc_config), - status: getString(resource.status), + agentVersion: toStringOrNull(resource.agent_version), + cid: toStringOrNull(resource.cid), + deviceId: toStringOrNull(resource.device_id), + heartbeatTime: toNumberOrNull(resource.heartbeat_time), + hostname: toStringOrNull(resource.hostname), + idpPolicyId: toStringOrNull(resource.idp_policy_id), + idpPolicyName: toStringOrNull(resource.idp_policy_name), + ipAddress: toStringOrNull(resource.local_ip), + kerberosConfig: toStringOrNull(resource.kerberos_config), + ldapConfig: toStringOrNull(resource.ldap_config), + ldapsConfig: toStringOrNull(resource.ldaps_config), + machineDomain: toStringOrNull(resource.machine_domain), + ntlmConfig: toStringOrNull(resource.ntlm_config), + osVersion: toStringOrNull(resource.os_version), + rdpToDcConfig: toStringOrNull(resource.rdp_to_dc_config), + smbToDcConfig: toStringOrNull(resource.smb_to_dc_config), + status: toStringOrNull(resource.status), statusCauses: getStringArray(resource.status_causes), - tiEnabled: getString(resource.ti_enabled), + tiEnabled: toStringOrNull(resource.ti_enabled), } } @@ -141,9 +139,9 @@ function normalizeAggregationResult( ): CrowdStrikeSensorAggregateResult { return { buckets: getRecordArray(resource.buckets).map(normalizeAggregationBucket), - docCountErrorUpperBound: getNumber(resource.doc_count_error_upper_bound), - name: getString(resource.name), - sumOtherDocCount: getNumber(resource.sum_other_doc_count), + docCountErrorUpperBound: toNumberOrNull(resource.doc_count_error_upper_bound), + name: toStringOrNull(resource.name), + sumOtherDocCount: toNumberOrNull(resource.sum_other_doc_count), } } @@ -151,16 +149,16 @@ function normalizeAggregationBucket( resource: Record ): CrowdStrikeSensorAggregateBucket { return { - count: getNumber(resource.count), - from: getNumber(resource.from), - keyAsString: getString(resource.key_as_string), + count: toNumberOrNull(resource.count), + from: toNumberOrNull(resource.from), + keyAsString: toStringOrNull(resource.key_as_string), label: resource.label ?? null, - stringFrom: getString(resource.string_from), - stringTo: getString(resource.string_to), + stringFrom: toStringOrNull(resource.string_from), + stringTo: toStringOrNull(resource.string_to), subAggregates: getRecordArray(resource.sub_aggregates).map(normalizeAggregationResult), - to: getNumber(resource.to), - value: getNumber(resource.value), - valueAsString: getString(resource.value_as_string), + to: toNumberOrNull(resource.to), + value: toNumberOrNull(resource.value), + valueAsString: toStringOrNull(resource.value_as_string), } } @@ -866,13 +864,13 @@ export async function executeCrowdStrikeOperation( return { ok: true, output: { - sessionId: getString(session.session_id), - deviceId: getString(session.device_id), - platform: getString(session.platform), - pwd: getString(session.pwd), - offlineQueued: getBoolean(session.offline_queued), - existingAidSessions: getNumber(session.existing_aid_sessions), - createdAt: getString(session.created_at), + sessionId: toStringOrNull(session.session_id), + deviceId: toStringOrNull(session.device_id), + platform: toStringOrNull(session.platform), + pwd: toStringOrNull(session.pwd), + offlineQueued: toBooleanOrNull(session.offline_queued), + existingAidSessions: toNumberOrNull(session.existing_aid_sessions), + createdAt: toStringOrNull(session.created_at), errors: getEnvelopeErrors(result.data), }, } @@ -896,9 +894,9 @@ export async function executeCrowdStrikeOperation( return { ok: true, output: { - cloudRequestId: getString(command.cloud_request_id), - sessionId: getString(command.session_id), - queuedCommandOffline: getBoolean(command.queued_command_offline), + cloudRequestId: toStringOrNull(command.cloud_request_id), + sessionId: toStringOrNull(command.session_id), + queuedCommandOffline: toBooleanOrNull(command.queued_command_offline), errors: getEnvelopeErrors(result.data), }, } @@ -921,13 +919,13 @@ export async function executeCrowdStrikeOperation( return { ok: true, output: { - complete: getBoolean(status.complete), - stdout: getString(status.stdout), - stderr: getString(status.stderr), - baseCommand: getString(status.base_command), - sessionId: getString(status.session_id), - taskId: getString(status.task_id), - sequenceId: getNumber(status.sequence_id), + complete: toBooleanOrNull(status.complete), + stdout: toStringOrNull(status.stdout), + stderr: toStringOrNull(status.stderr), + baseCommand: toStringOrNull(status.base_command), + sessionId: toStringOrNull(status.session_id), + taskId: toStringOrNull(status.task_id), + sequenceId: toNumberOrNull(status.sequence_id), errors: getEnvelopeErrors(result.data), }, } diff --git a/apps/sim/lib/internal/gmail/client.ts b/apps/sim/lib/internal/gmail/client.ts index f3a9bbf426b..0457fb6fb9c 100644 --- a/apps/sim/lib/internal/gmail/client.ts +++ b/apps/sim/lib/internal/gmail/client.ts @@ -1,3 +1,4 @@ +import { toArray } from '@sim/utils/object' import { type ReadResponseWithLimitOptions, readResponseJsonWithLimit, @@ -18,7 +19,7 @@ export function asObject(value: unknown): JsonObject { } export function asArray(value: unknown): unknown[] { - return Array.isArray(value) ? value : [] + return toArray(value) } export function nested(value: unknown, ...keys: string[]): unknown { diff --git a/apps/sim/lib/internal/jsm/client.ts b/apps/sim/lib/internal/jsm/client.ts index 1dfb8df2665..a44193dd634 100644 --- a/apps/sim/lib/internal/jsm/client.ts +++ b/apps/sim/lib/internal/jsm/client.ts @@ -1,3 +1,4 @@ +import { toArray } from '@sim/utils/object' import { validateJiraCloudId } from '@/lib/core/security/input-validation' import { JsmOperationError } from '@/lib/internal/jsm/errors' import { getJiraCloudId, parseAtlassianErrorMessage } from '@/tools/jira/utils' @@ -20,7 +21,7 @@ export function asObject(value: unknown): JsonObject { } export function asArray(value: unknown): unknown[] { - return Array.isArray(value) ? value : [] + return toArray(value) } export function nested(object: JsonObject, ...keys: string[]): unknown { diff --git a/apps/sim/lib/internal/vanta/normalizers.ts b/apps/sim/lib/internal/vanta/normalizers.ts index 149c1305745..85f921e73aa 100644 --- a/apps/sim/lib/internal/vanta/normalizers.ts +++ b/apps/sim/lib/internal/vanta/normalizers.ts @@ -1,3 +1,4 @@ +import { toBooleanOrNull, toStringOrNull } from '@sim/utils/coerce' import { isRecordLike, toRecord } from '@sim/utils/object' import type { VantaControl, @@ -37,18 +38,10 @@ export function asVantaRecord(value: unknown): JsonRecord { return toRecord(value) } -function getString(value: unknown): string | null { - return typeof value === 'string' ? value : null -} - function getNumber(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) ? value : null } -function getBoolean(value: unknown): boolean | null { - return typeof value === 'boolean' ? value : null -} - function getStringArray(value: unknown): string[] { if (!Array.isArray(value)) return [] return value.filter((entry): entry is string => typeof entry === 'string') @@ -66,14 +59,14 @@ export function extractVantaError(data: unknown, fallback: string): string { if (!isRecordLike(data)) return fallback if (isRecordLike(data.error)) { - const nested = getString(data.error.message) ?? getString(data.error.code) + const nested = toStringOrNull(data.error.message) ?? toStringOrNull(data.error.code) if (nested) return nested } return ( - getString(data.message) ?? - getString(data.error_description) ?? - getString(data.error) ?? + toStringOrNull(data.message) ?? + toStringOrNull(data.error_description) ?? + toStringOrNull(data.error) ?? fallback ) } @@ -136,35 +129,35 @@ export function getVantaListResults(data: unknown): { export function normalizeVantaPageInfo(value: unknown): VantaPageInfo | null { if (!isRecordLike(value)) return null return { - startCursor: getString(value.startCursor), - endCursor: getString(value.endCursor), - hasNextPage: getBoolean(value.hasNextPage) ?? false, - hasPreviousPage: getBoolean(value.hasPreviousPage) ?? false, + startCursor: toStringOrNull(value.startCursor), + endCursor: toStringOrNull(value.endCursor), + hasNextPage: toBooleanOrNull(value.hasNextPage) ?? false, + hasPreviousPage: toBooleanOrNull(value.hasPreviousPage) ?? false, } } function normalizeVantaOwner(value: unknown): VantaOwner | null { if (!isRecordLike(value)) return null return { - id: getString(value.id), - displayName: getString(value.displayName), - emailAddress: getString(value.emailAddress), + id: toStringOrNull(value.id), + displayName: toStringOrNull(value.displayName), + emailAddress: toStringOrNull(value.emailAddress), } } function normalizeVantaCustomFields(value: unknown): VantaCustomField[] { return getRecordArray(value).map((field) => ({ - label: getString(field.label), - value: Array.isArray(field.value) ? getStringArray(field.value) : getString(field.value), + label: toStringOrNull(field.label), + value: Array.isArray(field.value) ? getStringArray(field.value) : toStringOrNull(field.value), })) } export function normalizeVantaFramework(resource: JsonRecord): VantaFramework { return { - id: getString(resource.id), - displayName: getString(resource.displayName), - shorthandName: getString(resource.shorthandName), - description: getString(resource.description), + id: toStringOrNull(resource.id), + displayName: toStringOrNull(resource.displayName), + shorthandName: toStringOrNull(resource.shorthandName), + description: toStringOrNull(resource.description), numControlsCompleted: getNumber(resource.numControlsCompleted), numControlsTotal: getNumber(resource.numControlsTotal), numDocumentsPassing: getNumber(resource.numDocumentsPassing), @@ -178,19 +171,19 @@ function normalizeVantaFrameworkRequirementControl( resource: JsonRecord ): VantaFrameworkRequirementControl { return { - id: getString(resource.id), - externalId: getString(resource.externalId), - name: getString(resource.name), - description: getString(resource.description), + id: toStringOrNull(resource.id), + externalId: toStringOrNull(resource.externalId), + name: toStringOrNull(resource.name), + description: toStringOrNull(resource.description), } } function normalizeVantaFrameworkRequirement(resource: JsonRecord): VantaFrameworkRequirement { return { - id: getString(resource.id), - name: getString(resource.name), - shorthand: getString(resource.shorthand), - description: getString(resource.description), + id: toStringOrNull(resource.id), + name: toStringOrNull(resource.name), + shorthand: toStringOrNull(resource.shorthand), + description: toStringOrNull(resource.description), controls: getRecordArray(resource.controls).map(normalizeVantaFrameworkRequirementControl), } } @@ -199,9 +192,9 @@ function normalizeVantaFrameworkRequirementCategory( resource: JsonRecord ): VantaFrameworkRequirementCategory { return { - id: getString(resource.id), - name: getString(resource.name), - shorthand: getString(resource.shorthand), + id: toStringOrNull(resource.id), + name: toStringOrNull(resource.name), + shorthand: toStringOrNull(resource.shorthand), requirements: getRecordArray(resource.requirements).map(normalizeVantaFrameworkRequirement), } } @@ -217,25 +210,25 @@ export function normalizeVantaFrameworkDetail(resource: JsonRecord): VantaFramew export function normalizeVantaControl(resource: JsonRecord): VantaControl { return { - id: getString(resource.id), - externalId: getString(resource.externalId), - name: getString(resource.name), - description: getString(resource.description), - source: getString(resource.source), + id: toStringOrNull(resource.id), + externalId: toStringOrNull(resource.externalId), + name: toStringOrNull(resource.name), + description: toStringOrNull(resource.description), + source: toStringOrNull(resource.source), domains: getStringArray(resource.domains), owner: normalizeVantaOwner(resource.owner), - role: getString(resource.role), + role: toStringOrNull(resource.role), customFields: normalizeVantaCustomFields(resource.customFields), - creationDate: getString(resource.creationDate), - modificationDate: getString(resource.modificationDate), + creationDate: toStringOrNull(resource.creationDate), + modificationDate: toStringOrNull(resource.modificationDate), } } export function normalizeVantaControlDetail(resource: JsonRecord): VantaControlDetail { return { ...normalizeVantaControl(resource), - note: getString(resource.note), - status: getString(resource.status), + note: toStringOrNull(resource.note), + status: toStringOrNull(resource.status), numDocumentsPassing: getNumber(resource.numDocumentsPassing), numDocumentsTotal: getNumber(resource.numDocumentsTotal), numTestsPassing: getNumber(resource.numTestsPassing), @@ -249,30 +242,32 @@ export function normalizeVantaTest(resource: JsonRecord): VantaTest { : null const deactivatedStatusInfo = isRecordLike(resource.deactivatedStatusInfo) ? { - isDeactivated: getBoolean(resource.deactivatedStatusInfo.isDeactivated), - deactivatedReason: getString(resource.deactivatedStatusInfo.deactivatedReason), - lastUpdatedDate: getString(resource.deactivatedStatusInfo.lastUpdatedDate), + isDeactivated: toBooleanOrNull(resource.deactivatedStatusInfo.isDeactivated), + deactivatedReason: toStringOrNull(resource.deactivatedStatusInfo.deactivatedReason), + lastUpdatedDate: toStringOrNull(resource.deactivatedStatusInfo.lastUpdatedDate), } : null const remediationStatusInfo = isRecordLike(resource.remediationStatusInfo) ? { - status: getString(resource.remediationStatusInfo.status), - soonestRemediateByDate: getString(resource.remediationStatusInfo.soonestRemediateByDate), + status: toStringOrNull(resource.remediationStatusInfo.status), + soonestRemediateByDate: toStringOrNull( + resource.remediationStatusInfo.soonestRemediateByDate + ), itemCount: getNumber(resource.remediationStatusInfo.itemCount), } : null return { - id: getString(resource.id), - name: getString(resource.name), - description: getString(resource.description), - failureDescription: getString(resource.failureDescription), - remediationDescription: getString(resource.remediationDescription), - category: getString(resource.category), - status: getString(resource.status), + id: toStringOrNull(resource.id), + name: toStringOrNull(resource.name), + description: toStringOrNull(resource.description), + failureDescription: toStringOrNull(resource.failureDescription), + remediationDescription: toStringOrNull(resource.remediationDescription), + category: toStringOrNull(resource.category), + status: toStringOrNull(resource.status), integrations: getStringArray(resource.integrations), - lastTestRunDate: getString(resource.lastTestRunDate), - latestFlipDate: getString(resource.latestFlipDate), + lastTestRunDate: toStringOrNull(resource.lastTestRunDate), + latestFlipDate: toStringOrNull(resource.latestFlipDate), version, deactivatedStatusInfo, remediationStatusInfo, @@ -282,46 +277,46 @@ export function normalizeVantaTest(resource: JsonRecord): VantaTest { export function normalizeVantaTestEntity(resource: JsonRecord): VantaTestEntity { return { - id: getString(resource.id), - entityStatus: getString(resource.entityStatus), - displayName: getString(resource.displayName), - responseType: getString(resource.responseType), - deactivatedReason: getString(resource.deactivatedReason), - createdDate: getString(resource.createdDate), - lastUpdatedDate: getString(resource.lastUpdatedDate), + id: toStringOrNull(resource.id), + entityStatus: toStringOrNull(resource.entityStatus), + displayName: toStringOrNull(resource.displayName), + responseType: toStringOrNull(resource.responseType), + deactivatedReason: toStringOrNull(resource.deactivatedReason), + createdDate: toStringOrNull(resource.createdDate), + lastUpdatedDate: toStringOrNull(resource.lastUpdatedDate), } } export function normalizeVantaDocument(resource: JsonRecord): VantaDocument { return { - id: getString(resource.id), - title: getString(resource.title), - description: getString(resource.description), - category: getString(resource.category), - ownerId: getString(resource.ownerId), - isSensitive: getBoolean(resource.isSensitive), - uploadStatus: getString(resource.uploadStatus), - uploadStatusDate: getString(resource.uploadStatusDate), - url: getString(resource.url), + id: toStringOrNull(resource.id), + title: toStringOrNull(resource.title), + description: toStringOrNull(resource.description), + category: toStringOrNull(resource.category), + ownerId: toStringOrNull(resource.ownerId), + isSensitive: toBooleanOrNull(resource.isSensitive), + uploadStatus: toStringOrNull(resource.uploadStatus), + uploadStatusDate: toStringOrNull(resource.uploadStatusDate), + url: toStringOrNull(resource.url), } } export function normalizeVantaDocumentDetail(resource: JsonRecord): VantaDocumentDetail { const deactivatedStatus = isRecordLike(resource.deactivatedStatus) ? { - isDeactivated: getBoolean(resource.deactivatedStatus.isDeactivated), - reason: getString(resource.deactivatedStatus.reason), - creationDate: getString(resource.deactivatedStatus.creationDate), - expiration: getString(resource.deactivatedStatus.expiration), + isDeactivated: toBooleanOrNull(resource.deactivatedStatus.isDeactivated), + reason: toStringOrNull(resource.deactivatedStatus.reason), + creationDate: toStringOrNull(resource.deactivatedStatus.creationDate), + expiration: toStringOrNull(resource.deactivatedStatus.expiration), } : null return { ...normalizeVantaDocument(resource), - note: getString(resource.note), - nextRenewalDate: getString(resource.nextRenewalDate), - renewalCadence: getString(resource.renewalCadence), - reminderWindow: getString(resource.reminderWindow), + note: toStringOrNull(resource.note), + nextRenewalDate: toStringOrNull(resource.nextRenewalDate), + renewalCadence: toStringOrNull(resource.renewalCadence), + reminderWindow: toStringOrNull(resource.reminderWindow), subscribers: getStringArray(resource.subscribers), deactivatedStatus, } @@ -329,59 +324,59 @@ export function normalizeVantaDocumentDetail(resource: JsonRecord): VantaDocumen export function normalizeVantaUploadedFile(resource: JsonRecord): VantaUploadedFile { const uploadedBy = isRecordLike(resource.uploadedBy) - ? { id: getString(resource.uploadedBy.id), type: getString(resource.uploadedBy.type) } + ? { id: toStringOrNull(resource.uploadedBy.id), type: toStringOrNull(resource.uploadedBy.type) } : null return { - id: getString(resource.id), - fileName: getString(resource.fileName), - title: getString(resource.title), - description: getString(resource.description), - mimeType: getString(resource.mimeType), + id: toStringOrNull(resource.id), + fileName: toStringOrNull(resource.fileName), + title: toStringOrNull(resource.title), + description: toStringOrNull(resource.description), + mimeType: toStringOrNull(resource.mimeType), uploadedBy, - creationDate: getString(resource.creationDate), - updatedDate: getString(resource.updatedDate), - deletionDate: getString(resource.deletionDate), - effectiveDate: getString(resource.effectiveDate), - url: getString(resource.url), + creationDate: toStringOrNull(resource.creationDate), + updatedDate: toStringOrNull(resource.updatedDate), + deletionDate: toStringOrNull(resource.deletionDate), + effectiveDate: toStringOrNull(resource.effectiveDate), + url: toStringOrNull(resource.url), } } export function normalizeVantaPerson(resource: JsonRecord): VantaPerson { const name = isRecordLike(resource.name) ? { - first: getString(resource.name.first), - last: getString(resource.name.last), - display: getString(resource.name.display), + first: toStringOrNull(resource.name.first), + last: toStringOrNull(resource.name.last), + display: toStringOrNull(resource.name.display), } : null const employment = isRecordLike(resource.employment) ? { - status: getString(resource.employment.status), - startDate: getString(resource.employment.startDate), - endDate: getString(resource.employment.endDate), - jobTitle: getString(resource.employment.jobTitle), + status: toStringOrNull(resource.employment.status), + startDate: toStringOrNull(resource.employment.startDate), + endDate: toStringOrNull(resource.employment.endDate), + jobTitle: toStringOrNull(resource.employment.jobTitle), } : null const leaveInfo = isRecordLike(resource.leaveInfo) ? { - status: getString(resource.leaveInfo.status), - startDate: getString(resource.leaveInfo.startDate), - endDate: getString(resource.leaveInfo.endDate), + status: toStringOrNull(resource.leaveInfo.status), + startDate: toStringOrNull(resource.leaveInfo.startDate), + endDate: toStringOrNull(resource.leaveInfo.endDate), } : null const tasksSummary = isRecordLike(resource.tasksSummary) ? { - status: getString(resource.tasksSummary.status), - dueDate: getString(resource.tasksSummary.dueDate), - completionDate: getString(resource.tasksSummary.completionDate), + status: toStringOrNull(resource.tasksSummary.status), + dueDate: toStringOrNull(resource.tasksSummary.dueDate), + completionDate: toStringOrNull(resource.tasksSummary.completionDate), } : null return { - id: getString(resource.id), - userId: getString(resource.userId), - emailAddress: getString(resource.emailAddress), + id: toStringOrNull(resource.id), + userId: toStringOrNull(resource.userId), + emailAddress: toStringOrNull(resource.emailAddress), name, employment, leaveInfo, @@ -392,16 +387,16 @@ export function normalizeVantaPerson(resource: JsonRecord): VantaPerson { function normalizeVantaPolicyDocument(resource: JsonRecord): VantaPolicyDocument { return { - language: getString(resource.language), - slugId: getString(resource.slugId), - url: getString(resource.url), + language: toStringOrNull(resource.language), + slugId: toStringOrNull(resource.slugId), + url: toStringOrNull(resource.url), } } export function normalizeVantaPolicy(resource: JsonRecord): VantaPolicy { const latestApprovedVersion = isRecordLike(resource.latestApprovedVersion) ? { - versionId: getString(resource.latestApprovedVersion.versionId), + versionId: toStringOrNull(resource.latestApprovedVersion.versionId), documents: getRecordArray(resource.latestApprovedVersion.documents).map( normalizeVantaPolicyDocument ), @@ -409,13 +404,13 @@ export function normalizeVantaPolicy(resource: JsonRecord): VantaPolicy { : null return { - id: getString(resource.id), - name: getString(resource.name), - description: getString(resource.description), - status: getString(resource.status), - approvedAtDate: getString(resource.approvedAtDate), + id: toStringOrNull(resource.id), + name: toStringOrNull(resource.name), + description: toStringOrNull(resource.description), + status: toStringOrNull(resource.status), + approvedAtDate: toStringOrNull(resource.approvedAtDate), latestVersionStatus: isRecordLike(resource.latestVersion) - ? getString(resource.latestVersion.status) + ? toStringOrNull(resource.latestVersion.status) : null, latestApprovedVersion, } @@ -424,56 +419,58 @@ export function normalizeVantaPolicy(resource: JsonRecord): VantaPolicy { export function normalizeVantaVendor(resource: JsonRecord): VantaVendor { const authDetails = isRecordLike(resource.authDetails) ? { - method: getString(resource.authDetails.method), - passwordMFA: getBoolean(resource.authDetails.passwordMFA), + method: toStringOrNull(resource.authDetails.method), + passwordMFA: toBooleanOrNull(resource.authDetails.passwordMFA), passwordMinimumLength: getNumber(resource.authDetails.passwordMinimumLength), - passwordRequiresNumber: getBoolean(resource.authDetails.passwordRequiresNumber), - passwordRequiresSymbol: getBoolean(resource.authDetails.passwordRequiresSymbol), + passwordRequiresNumber: toBooleanOrNull(resource.authDetails.passwordRequiresNumber), + passwordRequiresSymbol: toBooleanOrNull(resource.authDetails.passwordRequiresSymbol), } : null const contractAmount = isRecordLike(resource.contractAmount) ? { amount: getNumber(resource.contractAmount.amount), - currency: getString(resource.contractAmount.currency), + currency: toStringOrNull(resource.contractAmount.currency), } : null const latestDecision = isRecordLike(resource.latestDecision) ? { - status: getString(resource.latestDecision.status), - lastUpdatedAt: getString(resource.latestDecision.lastUpdatedAt), + status: toStringOrNull(resource.latestDecision.status), + lastUpdatedAt: toStringOrNull(resource.latestDecision.lastUpdatedAt), } : null const procurementRequest = isRecordLike(resource.linkedTaskTrackerTaskProcurementRequest) ? { - url: getString(resource.linkedTaskTrackerTaskProcurementRequest.url), - service: getString(resource.linkedTaskTrackerTaskProcurementRequest.service), + url: toStringOrNull(resource.linkedTaskTrackerTaskProcurementRequest.url), + service: toStringOrNull(resource.linkedTaskTrackerTaskProcurementRequest.service), } : null return { - id: getString(resource.id), - name: getString(resource.name), - status: getString(resource.status), - websiteUrl: getString(resource.websiteUrl), - category: isRecordLike(resource.category) ? getString(resource.category.displayName) : null, - servicesProvided: getString(resource.servicesProvided), - additionalNotes: getString(resource.additionalNotes), - accountManagerName: getString(resource.accountManagerName), - accountManagerEmail: getString(resource.accountManagerEmail), - securityOwnerUserId: getString(resource.securityOwnerUserId), - businessOwnerUserId: getString(resource.businessOwnerUserId), - inherentRiskLevel: getString(resource.inherentRiskLevel), - residualRiskLevel: getString(resource.residualRiskLevel), - isRiskAutoScored: getBoolean(resource.isRiskAutoScored), - isVisibleToAuditors: getBoolean(resource.isVisibleToAuditors), + id: toStringOrNull(resource.id), + name: toStringOrNull(resource.name), + status: toStringOrNull(resource.status), + websiteUrl: toStringOrNull(resource.websiteUrl), + category: isRecordLike(resource.category) + ? toStringOrNull(resource.category.displayName) + : null, + servicesProvided: toStringOrNull(resource.servicesProvided), + additionalNotes: toStringOrNull(resource.additionalNotes), + accountManagerName: toStringOrNull(resource.accountManagerName), + accountManagerEmail: toStringOrNull(resource.accountManagerEmail), + securityOwnerUserId: toStringOrNull(resource.securityOwnerUserId), + businessOwnerUserId: toStringOrNull(resource.businessOwnerUserId), + inherentRiskLevel: toStringOrNull(resource.inherentRiskLevel), + residualRiskLevel: toStringOrNull(resource.residualRiskLevel), + isRiskAutoScored: toBooleanOrNull(resource.isRiskAutoScored), + isVisibleToAuditors: toBooleanOrNull(resource.isVisibleToAuditors), riskAttributeIds: getStringArray(resource.riskAttributeIds), - vendorHeadquarters: getString(resource.vendorHeadquarters), - contractStartDate: getString(resource.contractStartDate), - contractRenewalDate: getString(resource.contractRenewalDate), - contractTerminationDate: getString(resource.contractTerminationDate), + vendorHeadquarters: toStringOrNull(resource.vendorHeadquarters), + contractStartDate: toStringOrNull(resource.contractStartDate), + contractRenewalDate: toStringOrNull(resource.contractRenewalDate), + contractTerminationDate: toStringOrNull(resource.contractTerminationDate), contractAmount, - nextSecurityReviewDueDate: getString(resource.nextSecurityReviewDueDate), - lastSecurityReviewCompletionDate: getString(resource.lastSecurityReviewCompletionDate), + nextSecurityReviewDueDate: toStringOrNull(resource.nextSecurityReviewDueDate), + lastSecurityReviewCompletionDate: toStringOrNull(resource.lastSecurityReviewCompletionDate), authDetails, customFields: normalizeVantaCustomFields(resource.customFields), latestDecision, @@ -482,52 +479,52 @@ export function normalizeVantaVendor(resource: JsonRecord): VantaVendor { } function getComputerStatusOutcome(value: unknown): string | null { - return isRecordLike(value) ? getString(value.outcome) : null + return isRecordLike(value) ? toStringOrNull(value.outcome) : null } export function normalizeVantaMonitoredComputer(resource: JsonRecord): VantaMonitoredComputer { const operatingSystem = isRecordLike(resource.operatingSystem) ? { - type: getString(resource.operatingSystem.type), - version: getString(resource.operatingSystem.version), + type: toStringOrNull(resource.operatingSystem.type), + version: toStringOrNull(resource.operatingSystem.version), } : null return { - id: getString(resource.id), - integrationId: getString(resource.integrationId), - lastCheckDate: getString(resource.lastCheckDate), + id: toStringOrNull(resource.id), + integrationId: toStringOrNull(resource.integrationId), + lastCheckDate: toStringOrNull(resource.lastCheckDate), screenlock: getComputerStatusOutcome(resource.screenlock), diskEncryption: getComputerStatusOutcome(resource.diskEncryption), passwordManager: getComputerStatusOutcome(resource.passwordManager), antivirusInstallation: getComputerStatusOutcome(resource.antivirusInstallation), operatingSystem, owner: normalizeVantaOwner(resource.owner), - serialNumber: getString(resource.serialNumber), - udid: getString(resource.udid), + serialNumber: toStringOrNull(resource.serialNumber), + udid: toStringOrNull(resource.udid), } } export function normalizeVantaRiskScenario(resource: JsonRecord): VantaRiskScenario { return { - riskId: getString(resource.riskId), - description: getString(resource.description), + riskId: toStringOrNull(resource.riskId), + description: toStringOrNull(resource.description), likelihood: getNumber(resource.likelihood), impact: getNumber(resource.impact), residualLikelihood: getNumber(resource.residualLikelihood), residualImpact: getNumber(resource.residualImpact), categories: getStringArray(resource.categories), ciaCategories: getStringArray(resource.ciaCategories), - treatment: getString(resource.treatment), - owner: getString(resource.owner), - note: getString(resource.note), - riskRegister: getString(resource.riskRegister), + treatment: toStringOrNull(resource.treatment), + owner: toStringOrNull(resource.owner), + note: toStringOrNull(resource.note), + riskRegister: toStringOrNull(resource.riskRegister), customFields: normalizeVantaCustomFields(resource.customFields), - isArchived: getBoolean(resource.isArchived), - reviewStatus: getString(resource.reviewStatus), + isArchived: toBooleanOrNull(resource.isArchived), + reviewStatus: toStringOrNull(resource.reviewStatus), requiredApprovers: getStringArray(resource.requiredApprovers), - type: getString(resource.type), - identificationDate: getString(resource.identificationDate), + type: toStringOrNull(resource.type), + identificationDate: toStringOrNull(resource.identificationDate), } } @@ -535,30 +532,30 @@ export function normalizeVantaVulnerabilityRemediation( resource: JsonRecord ): VantaVulnerabilityRemediation { return { - id: getString(resource.id), - vulnerabilityId: getString(resource.vulnerabilityId), - vulnerableAssetId: getString(resource.vulnerableAssetId), - severity: getString(resource.severity), - detectedDate: getString(resource.detectedDate), - slaDeadlineDate: getString(resource.slaDeadlineDate), - remediationDate: getString(resource.remediationDate), + id: toStringOrNull(resource.id), + vulnerabilityId: toStringOrNull(resource.vulnerabilityId), + vulnerableAssetId: toStringOrNull(resource.vulnerableAssetId), + severity: toStringOrNull(resource.severity), + detectedDate: toStringOrNull(resource.detectedDate), + slaDeadlineDate: toStringOrNull(resource.slaDeadlineDate), + remediationDate: toStringOrNull(resource.remediationDate), } } function normalizeVantaVulnerableAssetScanner(resource: JsonRecord): VantaVulnerableAssetScanner { return { - resourceId: getString(resource.resourceId), - integrationId: getString(resource.integrationId), - targetId: getString(resource.targetId), - imageDigest: getString(resource.imageDigest), - imagePushedAtDate: getString(resource.imagePushedAtDate), + resourceId: toStringOrNull(resource.resourceId), + integrationId: toStringOrNull(resource.integrationId), + targetId: toStringOrNull(resource.targetId), + imageDigest: toStringOrNull(resource.imageDigest), + imagePushedAtDate: toStringOrNull(resource.imagePushedAtDate), imageTags: getStringArray(resource.imageTags), assetTags: getRecordArray(resource.assetTags).map((tag) => ({ - key: getString(tag.key), - value: getString(tag.value), + key: toStringOrNull(tag.key), + value: toStringOrNull(tag.value), })), - parentAccountOrOrganization: getString(resource.parentAccountOrOrganization), - biosUuid: getString(resource.biosUuid), + parentAccountOrOrganization: toStringOrNull(resource.parentAccountOrOrganization), + biosUuid: toStringOrNull(resource.biosUuid), ipv4s: getStringArray(resource.ipv4s), ipv6s: getStringArray(resource.ipv6s), macAddresses: getStringArray(resource.macAddresses), @@ -570,11 +567,11 @@ function normalizeVantaVulnerableAssetScanner(resource: JsonRecord): VantaVulner export function normalizeVantaVulnerableAsset(resource: JsonRecord): VantaVulnerableAsset { return { - id: getString(resource.id), - name: getString(resource.name), - assetType: getString(resource.assetType), - hasBeenScanned: getBoolean(resource.hasBeenScanned), - imageScanTag: getString(resource.imageScanTag), + id: toStringOrNull(resource.id), + name: toStringOrNull(resource.name), + assetType: toStringOrNull(resource.assetType), + hasBeenScanned: toBooleanOrNull(resource.hasBeenScanned), + imageScanTag: toStringOrNull(resource.imageScanTag), scanners: getRecordArray(resource.scanners).map(normalizeVantaVulnerableAssetScanner), } } @@ -582,35 +579,35 @@ export function normalizeVantaVulnerableAsset(resource: JsonRecord): VantaVulner export function normalizeVantaVulnerability(resource: JsonRecord): VantaVulnerability { const deactivateMetadata = isRecordLike(resource.deactivateMetadata) ? { - isVulnDeactivatedIndefinitely: getBoolean( + isVulnDeactivatedIndefinitely: toBooleanOrNull( resource.deactivateMetadata.isVulnDeactivatedIndefinitely ), - deactivatedUntilDate: getString(resource.deactivateMetadata.deactivatedUntilDate), - deactivationReason: getString(resource.deactivateMetadata.deactivationReason), - deactivatedOnDate: getString(resource.deactivateMetadata.deactivatedOnDate), - deactivatedBy: getString(resource.deactivateMetadata.deactivatedBy), + deactivatedUntilDate: toStringOrNull(resource.deactivateMetadata.deactivatedUntilDate), + deactivationReason: toStringOrNull(resource.deactivateMetadata.deactivationReason), + deactivatedOnDate: toStringOrNull(resource.deactivateMetadata.deactivatedOnDate), + deactivatedBy: toStringOrNull(resource.deactivateMetadata.deactivatedBy), } : null return { - id: getString(resource.id), - name: getString(resource.name), - description: getString(resource.description), - severity: getString(resource.severity), - vulnerabilityType: getString(resource.vulnerabilityType), - integrationId: getString(resource.integrationId), - targetId: getString(resource.targetId), - packageIdentifier: getString(resource.packageIdentifier), + id: toStringOrNull(resource.id), + name: toStringOrNull(resource.name), + description: toStringOrNull(resource.description), + severity: toStringOrNull(resource.severity), + vulnerabilityType: toStringOrNull(resource.vulnerabilityType), + integrationId: toStringOrNull(resource.integrationId), + targetId: toStringOrNull(resource.targetId), + packageIdentifier: toStringOrNull(resource.packageIdentifier), cvssSeverityScore: getNumber(resource.cvssSeverityScore), scannerScore: getNumber(resource.scannerScore), - isFixable: getBoolean(resource.isFixable), - fixedVersion: getString(resource.fixedVersion), - remediateByDate: getString(resource.remediateByDate), - firstDetectedDate: getString(resource.firstDetectedDate), - sourceDetectedDate: getString(resource.sourceDetectedDate), - lastDetectedDate: getString(resource.lastDetectedDate), - scanSource: getString(resource.scanSource), - externalURL: getString(resource.externalURL), + isFixable: toBooleanOrNull(resource.isFixable), + fixedVersion: toStringOrNull(resource.fixedVersion), + remediateByDate: toStringOrNull(resource.remediateByDate), + firstDetectedDate: toStringOrNull(resource.firstDetectedDate), + sourceDetectedDate: toStringOrNull(resource.sourceDetectedDate), + lastDetectedDate: toStringOrNull(resource.lastDetectedDate), + scanSource: toStringOrNull(resource.scanSource), + externalURL: toStringOrNull(resource.externalURL), relatedVulns: getStringArray(resource.relatedVulns), relatedUrls: getStringArray(resource.relatedUrls), deactivateMetadata, diff --git a/apps/sim/lib/webhooks/providers/bitbucket.ts b/apps/sim/lib/webhooks/providers/bitbucket.ts index 589c81ede5a..c2664f94544 100644 --- a/apps/sim/lib/webhooks/providers/bitbucket.ts +++ b/apps/sim/lib/webhooks/providers/bitbucket.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' import { hmacSha256Hex } from '@sim/security/hmac' +import { toStringOrNull } from '@sim/utils/coerce' import { getErrorMessage, toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { toRecord, toRecordOrNull } from '@sim/utils/object' @@ -227,7 +228,7 @@ async function findBitbucketCandidateHook( } const matchedHook = toRecord(matchingHooks[0]) - const externalId = nullableString(matchedHook.uuid)?.trim() + const externalId = toStringOrNull(matchedHook.uuid)?.trim() return externalId ? { kind: 'found', @@ -312,10 +313,6 @@ async function rollbackAmbiguousBitbucketCandidate( ) } -function nullableString(value: unknown): string | null { - return typeof value === 'string' ? value : null -} - function nullableNumber(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) ? value : null } @@ -338,7 +335,7 @@ function commentFields(body: Record): Record { return { comment, commentId: nullableNumber(comment?.id), - commentContent: nullableString(content.raw), + commentContent: toStringOrNull(content.raw), } } @@ -352,17 +349,17 @@ function pullRequestFields(body: Record): Record | null): string | null { const links = toRecord(commitStatus?.links) const commit = toRecord(links.commit) - const href = nullableString(commit.href) + const href = toStringOrNull(commit.href) if (!href) return null try { @@ -409,10 +406,10 @@ function formatBitbucketInput( ...base, commitStatus, commitHash: commitHashFromStatus(commitStatus), - statusKey: nullableString(commitStatus?.key), - statusState: nullableString(commitStatus?.state), - statusName: nullableString(commitStatus?.name), - statusUrl: nullableString(commitStatus?.url), + statusKey: toStringOrNull(commitStatus?.key), + statusState: toStringOrNull(commitStatus?.state), + statusName: toStringOrNull(commitStatus?.name), + statusUrl: toStringOrNull(commitStatus?.url), } } if (triggerId && PULL_REQUEST_TRIGGER_IDS.has(triggerId)) { @@ -479,7 +476,7 @@ export const bitbucketHandler: WebhookProviderHandler = { const credentialId = config.credentialId as string | undefined const workspaceSlug = readRequiredConfigString(config, 'workspaceSlug', 'workspace') const repoSlug = readRequiredConfigString(config, 'repoSlug', 'repository') - const webhookId = nullableString(ctx.webhook.id)?.trim() + const webhookId = toStringOrNull(ctx.webhook.id)?.trim() if (!webhookId) { throw new Error('Bitbucket webhook ID is required to manage the repository webhook.') } @@ -513,8 +510,8 @@ export const bitbucketHandler: WebhookProviderHandler = { ) } if (existingCandidate.kind === 'found') { - const checkpointedExternalId = nullableString(config.externalId)?.trim() - const checkpointedSecret = nullableString(config.webhookSecret)?.trim() + const checkpointedExternalId = toStringOrNull(config.externalId)?.trim() + const checkpointedSecret = toStringOrNull(config.webhookSecret)?.trim() const candidateMatchesCheckpoint = checkpointedExternalId === existingCandidate.externalId && Boolean(checkpointedSecret) && @@ -601,7 +598,7 @@ export const bitbucketHandler: WebhookProviderHandler = { } const created = toRecord(await response.json().catch(() => null)) - const externalId = nullableString(created.uuid)?.trim() || null + const externalId = toStringOrNull(created.uuid)?.trim() || null if (!externalId) { if (isStableCandidate) { await rollbackAmbiguousBitbucketCandidate( diff --git a/apps/sim/lib/webhooks/providers/incidentio.ts b/apps/sim/lib/webhooks/providers/incidentio.ts index 25011781744..ff0eecba698 100644 --- a/apps/sim/lib/webhooks/providers/incidentio.ts +++ b/apps/sim/lib/webhooks/providers/incidentio.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { safeCompare } from '@sim/security/compare' import { hmacSha256Base64 } from '@sim/security/hmac' +import { toStringOrNull } from '@sim/utils/coerce' import { toRecordOrNull } from '@sim/utils/object' import { NextResponse } from 'next/server' import type { @@ -57,10 +58,6 @@ function verifyIncidentioSignature( } } -function asString(value: unknown): string | null { - return typeof value === 'string' ? value : null -} - /** * Locate a named entity (incident/alert) within an incident.io webhook body. * @@ -157,16 +154,16 @@ export const incidentioHandler: WebhookProviderHandler = { input: { event_type: eventType, alert, - alert_id: asString(alert?.id), - title: asString(alert?.title), - description: asString(alert?.description), - status: asString(alert?.status), - alert_source_id: asString(alert?.alert_source_id), - deduplication_key: asString(alert?.deduplication_key), - source_url: asString(alert?.source_url), - created_at: asString(alert?.created_at), - updated_at: asString(alert?.updated_at), - resolved_at: asString(alert?.resolved_at), + alert_id: toStringOrNull(alert?.id), + title: toStringOrNull(alert?.title), + description: toStringOrNull(alert?.description), + status: toStringOrNull(alert?.status), + alert_source_id: toStringOrNull(alert?.alert_source_id), + deduplication_key: toStringOrNull(alert?.deduplication_key), + source_url: toStringOrNull(alert?.source_url), + created_at: toStringOrNull(alert?.created_at), + updated_at: toStringOrNull(alert?.updated_at), + resolved_at: toStringOrNull(alert?.resolved_at), payload: b, }, } @@ -177,20 +174,20 @@ export const incidentioHandler: WebhookProviderHandler = { input: { event_type: eventType, incident, - incident_id: asString(incident?.id), - name: asString(incident?.name), - reference: asString(incident?.reference), - summary: asString(incident?.summary), + incident_id: toStringOrNull(incident?.id), + name: toStringOrNull(incident?.name), + reference: toStringOrNull(incident?.reference), + summary: toStringOrNull(incident?.summary), incident_status: toRecordOrNull(incident?.incident_status), severity: toRecordOrNull(incident?.severity), - mode: asString(incident?.mode), - visibility: asString(incident?.visibility), - permalink: asString(incident?.permalink), - created_at: asString(incident?.created_at), - updated_at: asString(incident?.updated_at), + mode: toStringOrNull(incident?.mode), + visibility: toStringOrNull(incident?.visibility), + permalink: toStringOrNull(incident?.permalink), + created_at: toStringOrNull(incident?.created_at), + updated_at: toStringOrNull(incident?.updated_at), new_status: toRecordOrNull(wrapper?.new_status), previous_status: toRecordOrNull(wrapper?.previous_status), - update_message: asString(wrapper?.message), + update_message: toStringOrNull(wrapper?.message), payload: b, }, } diff --git a/apps/sim/lib/webhooks/providers/instantly.ts b/apps/sim/lib/webhooks/providers/instantly.ts index e35d5aab179..ff8089a867e 100644 --- a/apps/sim/lib/webhooks/providers/instantly.ts +++ b/apps/sim/lib/webhooks/providers/instantly.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { toBooleanOrNull, toStringOrNull } from '@sim/utils/coerce' import { toError } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' import { isRecordLike, toRecord, toRecordOrNull } from '@sim/utils/object' @@ -278,18 +279,10 @@ function extractInstantlyError(body: Record | null): string | n return null } -function toStringOrNull(value: unknown): string | null { - return typeof value === 'string' ? value : null -} - function toNumberOrNull(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) ? value : null } -function toBooleanOrNull(value: unknown): boolean | null { - return typeof value === 'boolean' ? value : null -} - function optionalId(value: unknown): string | undefined { if (typeof value !== 'string') return undefined const trimmed = value.trim() diff --git a/apps/sim/tools/azure_data_explorer/utils.ts b/apps/sim/tools/azure_data_explorer/utils.ts index e4937083ee4..2f04d940fc0 100644 --- a/apps/sim/tools/azure_data_explorer/utils.ts +++ b/apps/sim/tools/azure_data_explorer/utils.ts @@ -1,3 +1,4 @@ +import { toStringOrNull } from '@sim/utils/coerce' import type { AzureDataExplorerBaseParams, AzureDataExplorerTable, @@ -265,10 +266,6 @@ export function transformColumnListResponse(columnName: string } } -function stringOrNull(value: unknown): string | null { - return typeof value === 'string' ? value : null -} - /** * Projects `.show table ... cslschema`, whose single row carries the documented * TableName, Schema, DatabaseName, Folder, and DocString columns. @@ -279,11 +276,11 @@ export async function transformTableSchemaResponse(response: Response) { return { success: true as const, output: { - tableName: stringOrNull(record.TableName), - schema: stringOrNull(record.Schema), - databaseName: stringOrNull(record.DatabaseName), - folder: stringOrNull(record.Folder), - docString: stringOrNull(record.DocString), + tableName: toStringOrNull(record.TableName), + schema: toStringOrNull(record.Schema), + databaseName: toStringOrNull(record.DatabaseName), + folder: toStringOrNull(record.Folder), + docString: toStringOrNull(record.DocString), }, } } diff --git a/apps/sim/tools/bitbucket/get_merge_task_status.ts b/apps/sim/tools/bitbucket/get_merge_task_status.ts index 04a08609fda..233c180ccdd 100644 --- a/apps/sim/tools/bitbucket/get_merge_task_status.ts +++ b/apps/sim/tools/bitbucket/get_merge_task_status.ts @@ -1,3 +1,4 @@ +import { toStringOrNull } from '@sim/utils/coerce' import { toRecordOrNull } from '@sim/utils/object' import { BITBUCKET_PULL_REQUEST_OUTPUT_PROPERTIES, @@ -24,10 +25,6 @@ interface BitbucketMergeTaskOutput { mergeResult: BitbucketPullRequest | null } -function stringField(value: unknown): string | null { - return typeof value === 'string' ? value : null -} - export const bitbucketGetMergeTaskStatusTool: ToolConfig< BitbucketGetMergeTaskStatusParams, BitbucketToolResponse @@ -57,9 +54,9 @@ export const bitbucketGetMergeTaskStatusTool: ToolConfig< const data = await bitbucketJson(response) if (data.type === 'error') { const error = toRecordOrNull(data.error) - const message = stringField(error?.message)?.trim() + const message = toStringOrNull(error?.message)?.trim() if (!message) throw new Error('Bitbucket returned a malformed merge task error') - const detail = stringField(error?.detail)?.trim() + const detail = toStringOrNull(error?.detail)?.trim() throw new Error(detail && detail !== message ? `${message}: ${detail}` : message) } @@ -81,7 +78,7 @@ export const bitbucketGetMergeTaskStatusTool: ToolConfig< success: true, output: { taskStatus, - selfUrl: stringField(self?.href), + selfUrl: toStringOrNull(self?.href), mergeResult, }, } diff --git a/apps/sim/tools/cbinsights/utils.ts b/apps/sim/tools/cbinsights/utils.ts index d092c2749ab..fc0c85d323a 100644 --- a/apps/sim/tools/cbinsights/utils.ts +++ b/apps/sim/tools/cbinsights/utils.ts @@ -1,4 +1,5 @@ import { getErrorMessage } from '@sim/utils/errors' +import { toArray } from '@sim/utils/object' import { LRUCache } from 'lru-cache' import { DEFAULT_MAX_ERROR_BODY_BYTES, @@ -552,7 +553,7 @@ export function pageInfo(data: { /** Narrows an optional array field to a list, never null. */ export function asArray(value: unknown): CbInsightsRecord[] { - return Array.isArray(value) ? (value as CbInsightsRecord[]) : [] + return toArray(value) } /** Narrows an optional string array. */ @@ -561,13 +562,3 @@ export function asStringArray(value: unknown): string[] { ? value.filter((entry): entry is string => typeof entry === 'string') : [] } - -/** Narrows an optional string field. */ -export function asString(value: unknown): string | null { - return typeof value === 'string' ? value : null -} - -/** Narrows an optional number field. */ -export function asNumber(value: unknown): number | null { - return typeof value === 'number' ? value : null -} diff --git a/apps/sim/tools/clickup/shared.ts b/apps/sim/tools/clickup/shared.ts index 07db3398853..3d78c9afb96 100644 --- a/apps/sim/tools/clickup/shared.ts +++ b/apps/sim/tools/clickup/shared.ts @@ -1,3 +1,4 @@ +import { toBooleanOrNull } from '@sim/utils/coerce' import { isRecordLike, toRecordOrNull } from '@sim/utils/object' import type { ClickUpAttachment, @@ -61,10 +62,6 @@ function getOptionalString(value: unknown): string | null { return null } -function getOptionalBoolean(value: unknown): boolean | null { - return typeof value === 'boolean' ? value : null -} - function getOptionalNumber(value: unknown): number | null { if (typeof value === 'number' && Number.isFinite(value)) { return value @@ -377,7 +374,7 @@ export function mapClickUpComment(value: unknown): ClickUpComment { return { id: getRequiredString(value.id, 'id'), commentText: getOptionalString(value.comment_text), - resolved: getOptionalBoolean(value.resolved), + resolved: toBooleanOrNull(value.resolved), user: mapClickUpUser(value.user), assignee: mapClickUpUser(value.assignee), date: getOptionalString(value.date), @@ -408,8 +405,8 @@ export function mapClickUpSpace(value: unknown): ClickUpSpace { return { id: getRequiredString(value.id, 'id'), name: getOptionalString(value.name), - private: getOptionalBoolean(value.private), - archived: getOptionalBoolean(value.archived), + private: toBooleanOrNull(value.private), + archived: toBooleanOrNull(value.archived), statuses: rawStatuses .map((status) => mapClickUpStatus(status)) .filter((status): status is ClickUpStatus => status !== null), @@ -424,7 +421,7 @@ export function mapClickUpFolder(value: unknown): ClickUpFolder { return { id: getRequiredString(value.id, 'id'), name: getOptionalString(value.name), - hidden: getOptionalBoolean(value.hidden), + hidden: toBooleanOrNull(value.hidden), taskCount: getOptionalString(value.task_count), space: mapIdName(value.space), } @@ -439,7 +436,7 @@ export function mapClickUpList(value: unknown): ClickUpList { id: getRequiredString(value.id, 'id'), name: getOptionalString(value.name), taskCount: getOptionalString(value.task_count), - archived: getOptionalBoolean(value.archived), + archived: toBooleanOrNull(value.archived), } } @@ -493,7 +490,7 @@ export function mapClickUpCustomField(value: unknown): ClickUpCustomField { type: getOptionalString(value.type), typeConfig: toRecordOrNull(value.type_config), dateCreated: getOptionalString(value.date_created), - hideFromGuests: getOptionalBoolean(value.hide_from_guests), + hideFromGuests: toBooleanOrNull(value.hide_from_guests), } } @@ -507,7 +504,7 @@ function mapClickUpChecklistItem(value: unknown): ClickUpChecklistItem { name: getOptionalString(value.name), orderIndex: getOptionalNumber(value.orderindex), assignee: mapClickUpUser(value.assignee), - resolved: getOptionalBoolean(value.resolved), + resolved: toBooleanOrNull(value.resolved), parent: getOptionalString(value.parent), dateCreated: getOptionalString(value.date_created), children: Array.isArray(value.children) @@ -549,7 +546,7 @@ export function mapClickUpTimeEntry(value: unknown): ClickUpTimeEntry { task: mapIdName(value.task), workspaceId: getOptionalString(value.wid), user: mapClickUpUser(value.user), - billable: getOptionalBoolean(value.billable), + billable: toBooleanOrNull(value.billable), start: getOptionalString(value.start), end: getOptionalString(value.end), duration: getOptionalNumber(value.duration), diff --git a/apps/sim/tools/coda/list_doc_analytics.ts b/apps/sim/tools/coda/list_doc_analytics.ts index 12ca60f6dd9..6af5b71eeb2 100644 --- a/apps/sim/tools/coda/list_doc_analytics.ts +++ b/apps/sim/tools/coda/list_doc_analytics.ts @@ -1,3 +1,4 @@ +import { toNumberOrNull } from '@sim/utils/coerce' import type { CodaDocAnalyticsItem, CodaListDocAnalyticsParams, @@ -47,10 +48,6 @@ interface RawDocAnalyticsItem { metrics?: Array & { date?: string }> } -function toNumberOrNull(value: unknown): number | null { - return typeof value === 'number' ? value : null -} - export const codaListDocAnalyticsTool: ToolConfig< CodaListDocAnalyticsParams, CodaListDocAnalyticsResponse diff --git a/apps/sim/tools/dynatrace/utils.ts b/apps/sim/tools/dynatrace/utils.ts index cfe853e8129..413e5941c74 100644 --- a/apps/sim/tools/dynatrace/utils.ts +++ b/apps/sim/tools/dynatrace/utils.ts @@ -1,4 +1,4 @@ -import { toRecord, toRecordOrNull } from '@sim/utils/object' +import { toArray, toRecord, toRecordOrNull } from '@sim/utils/object' import { truncate } from '@sim/utils/string' import type { DynatraceAttack, @@ -157,14 +157,6 @@ export async function readJsonBody(response: Response): Promise> { - return Array.isArray(value) ? (value as Array>) : [] -} - -function toStringArray(value: unknown): string[] { - return Array.isArray(value) ? (value as string[]) : [] -} - /** Flattens an `EntityStub` (`{ entityId: { id, type }, name }`) into a single object. */ export function mapEntityStub(stub: unknown): DynatraceEntityStub | null { const record = toRecordOrNull(stub) @@ -178,14 +170,14 @@ export function mapEntityStub(stub: unknown): DynatraceEntityStub | null { } function mapEntityStubs(value: unknown): DynatraceEntityStub[] { - return toRecordArray(value) + return toArray>(value) .map(mapEntityStub) .filter((stub): stub is DynatraceEntityStub => stub !== null) } /** Maps a `METag` list. */ export function mapTags(value: unknown): DynatraceTag[] { - return toRecordArray(value).map((tag) => ({ + return toArray>(value).map((tag) => ({ context: (tag.context as string) ?? null, key: (tag.key as string) ?? null, value: (tag.value as string) ?? null, @@ -195,14 +187,14 @@ export function mapTags(value: unknown): DynatraceTag[] { /** Maps a management zone list. */ export function mapManagementZones(value: unknown): DynatraceManagementZone[] { - return toRecordArray(value).map((zone) => ({ + return toArray>(value).map((zone) => ({ id: (zone.id as string) ?? null, name: (zone.name as string) ?? null, })) } function mapProblemFilters(value: unknown): DynatraceProblemFilter[] { - return toRecordArray(value).map((filter) => ({ + return toArray>(value).map((filter) => ({ id: (filter.id as string) ?? null, name: (filter.name as string) ?? null, })) @@ -275,19 +267,23 @@ export function mapEntityType(value: Record): DynatraceEntityTy displayName: (value.displayName as string) ?? null, dimensionKey: (value.dimensionKey as string) ?? null, entityLimitExceeded: (value.entityLimitExceeded as boolean) ?? null, - properties: toRecordArray(value.properties).map((property) => ({ + properties: toArray>(value.properties).map((property) => ({ id: (property.id as string) ?? null, displayName: (property.displayName as string) ?? null, type: (property.type as string) ?? null, })), - fromRelationships: toRecordArray(value.fromRelationships).map((relationship) => ({ - id: (relationship.id as string) ?? null, - toTypes: toStringArray(relationship.toTypes), - })), - toRelationships: toRecordArray(value.toRelationships).map((relationship) => ({ - id: (relationship.id as string) ?? null, - fromTypes: toStringArray(relationship.fromTypes), - })), + fromRelationships: toArray>(value.fromRelationships).map( + (relationship) => ({ + id: (relationship.id as string) ?? null, + toTypes: toArray(relationship.toTypes), + }) + ), + toRelationships: toArray>(value.toRelationships).map( + (relationship) => ({ + id: (relationship.id as string) ?? null, + fromTypes: toArray(relationship.fromTypes), + }) + ), } } @@ -306,7 +302,7 @@ export function mapEvent(value: Record): DynatraceEvent { suppressAlert: (value.suppressAlert as boolean) ?? null, suppressProblem: (value.suppressProblem as boolean) ?? null, entityId: mapEntityStub(value.entityId), - properties: toRecordArray(value.properties).map((property) => ({ + properties: toArray>(value.properties).map((property) => ({ key: (property.key as string) ?? null, value: (property.value as string) ?? null, })), @@ -325,9 +321,9 @@ export function mapMetricResult(value: Record): DynatraceMetric ? value.appliedOptionalFilters : [], dql: toRecordOrNull(value.dql), - warnings: toStringArray(value.warnings), - data: toRecordArray(value.data).map((series) => ({ - dimensions: toStringArray(series.dimensions), + warnings: toArray(value.warnings), + data: toArray>(value.data).map((series) => ({ + dimensions: toArray(series.dimensions), dimensionMap: toRecord(series.dimensionMap) as Record, timestamps: Array.isArray(series.timestamps) ? (series.timestamps as number[]) : [], values: Array.isArray(series.values) ? (series.values as Array) : [], @@ -343,17 +339,17 @@ export function mapMetricDescriptor(value: Record): DynatraceMe description: (value.description as string) ?? null, unit: (value.unit as string) ?? null, unitDisplayFormat: (value.unitDisplayFormat as string) ?? null, - tags: toStringArray(value.tags), + tags: toArray(value.tags), billable: (value.billable as boolean) ?? null, dduBillable: (value.dduBillable as boolean) ?? null, created: (value.created as number) ?? null, lastWritten: (value.lastWritten as number) ?? null, - aggregationTypes: toStringArray(value.aggregationTypes), + aggregationTypes: toArray(value.aggregationTypes), defaultAggregation: toRecordOrNull(value.defaultAggregation), - dimensionDefinitions: toRecordArray(value.dimensionDefinitions), - dimensionCardinalities: toRecordArray(value.dimensionCardinalities), - transformations: toStringArray(value.transformations), - entityType: toStringArray(value.entityType), + dimensionDefinitions: toArray>(value.dimensionDefinitions), + dimensionCardinalities: toArray>(value.dimensionCardinalities), + transformations: toArray(value.transformations), + entityType: toArray(value.entityType), minimumValue: (value.minimumValue as number) ?? null, maximumValue: (value.maximumValue as number) ?? null, rootCauseRelevant: (value.rootCauseRelevant as boolean) ?? null, @@ -363,7 +359,7 @@ export function mapMetricDescriptor(value: Record): DynatraceMe metricSelector: (value.metricSelector as string) ?? null, scalar: (value.scalar as boolean) ?? null, resolutionInfSupported: (value.resolutionInfSupported as boolean) ?? null, - warnings: toStringArray(value.warnings), + warnings: toArray(value.warnings), } } @@ -404,7 +400,7 @@ export function mapSecurityProblem(value: Record): DynatraceSec vulnerabilityType: (value.vulnerabilityType as string) ?? null, packageName: (value.packageName as string) ?? null, externalVulnerabilityId: (value.externalVulnerabilityId as string) ?? null, - cveIds: toStringArray(value.cveIds), + cveIds: toArray(value.cveIds), url: (value.url as string) ?? null, firstSeenTimestamp: (value.firstSeenTimestamp as number) ?? null, lastUpdatedTimestamp: (value.lastUpdatedTimestamp as number) ?? null, @@ -426,12 +422,12 @@ export function mapSecurityProblemDetails( description: (value.description as string) ?? null, remediationDescription: (value.remediationDescription as string) ?? null, muteStateChangeInProgress: (value.muteStateChangeInProgress as boolean) ?? null, - affectedEntities: toStringArray(value.affectedEntities), - exposedEntities: toStringArray(value.exposedEntities), - reachableDataAssets: toStringArray(value.reachableDataAssets), - vulnerableComponents: toRecordArray(value.vulnerableComponents), + affectedEntities: toArray(value.affectedEntities), + exposedEntities: toArray(value.exposedEntities), + reachableDataAssets: toArray(value.reachableDataAssets), + vulnerableComponents: toArray>(value.vulnerableComponents), filteredCounts: toRecordOrNull(value.filteredCounts), - events: toRecordArray(value.events), + events: toArray>(value.events), entryPoints: toRecordOrNull(value.entryPoints), relatedEntities: toRecordOrNull(value.relatedEntities), relatedAttacks: toRecordOrNull(value.relatedAttacks), @@ -495,7 +491,7 @@ export function toStringList(value: unknown): string[] { /** Maps the per-problem summary a batch mute/unmute returns. */ export function mapMuteSummary(value: unknown): DynatraceMuteSummaryEntry[] { - return toRecordArray(value).map((entry) => ({ + return toArray>(value).map((entry) => ({ securityProblemId: (entry.securityProblemId as string) ?? null, muteStateChangeTriggered: (entry.muteStateChangeTriggered as boolean) ?? null, reason: (entry.reason as string) ?? null, @@ -507,7 +503,7 @@ export function mapRemediationItem(value: Record): DynatraceRem return { id: (value.id as string) ?? null, name: (value.name as string) ?? null, - entityIds: toStringArray(value.entityIds), + entityIds: toArray(value.entityIds), firstAffectedTimestamp: (value.firstAffectedTimestamp as number) ?? null, resolvedTimestamp: (value.resolvedTimestamp as number) ?? null, vulnerabilityState: (value.vulnerabilityState as string) ?? null, @@ -515,7 +511,7 @@ export function mapRemediationItem(value: Record): DynatraceRem muteState: toRecordOrNull(value.muteState), remediationProgress: toRecordOrNull(value.remediationProgress), trackingLink: toRecordOrNull(value.trackingLink), - vulnerableComponents: toRecordArray(value.vulnerableComponents), + vulnerableComponents: toArray>(value.vulnerableComponents), } } diff --git a/apps/sim/tools/emailbison/utils.ts b/apps/sim/tools/emailbison/utils.ts index f580db63053..7e64a1ee60f 100644 --- a/apps/sim/tools/emailbison/utils.ts +++ b/apps/sim/tools/emailbison/utils.ts @@ -1,4 +1,4 @@ -import { filterUndefined, isRecordLike, toRecord } from '@sim/utils/object' +import { filterUndefined, isRecordLike, toArray, toRecord } from '@sim/utils/object' import type { EmailBisonBaseParams, EmailBisonCampaign, @@ -441,10 +441,6 @@ function mapReplyAttachment(value: unknown): EmailBisonReplyAttachment { } } -function toArray(value: unknown): unknown[] { - return Array.isArray(value) ? value : [] -} - function toStringOrNull(value: unknown): string | null { if (value === undefined || value === null) return null return String(value) diff --git a/apps/sim/tools/harmonic/utils.ts b/apps/sim/tools/harmonic/utils.ts index 7cdc32cce3f..49112dba9d3 100644 --- a/apps/sim/tools/harmonic/utils.ts +++ b/apps/sim/tools/harmonic/utils.ts @@ -1,3 +1,4 @@ +import { toBooleanOrNull } from '@sim/utils/coerce' import { toRecordOrNull } from '@sim/utils/object' import type { HarmonicContact, @@ -160,10 +161,6 @@ function asNumber(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) ? value : null } -function asBoolean(value: unknown): boolean | null { - return typeof value === 'boolean' ? value : null -} - function uniqueStrings(values: unknown[]): string[] { const seen = new Set() const result: string[] = [] @@ -490,7 +487,7 @@ export function normalizePerson(raw: HarmonicPersonOutput): HarmonicContact { country: asString(location.country), profilePictureUrl: asString(raw.profile_picture_url), summary: null, - isRedacted: asBoolean(raw.is_redacted), + isRedacted: toBooleanOrNull(raw.is_redacted), } } @@ -565,7 +562,7 @@ export function normalizeSavedSearch(raw: HarmonicSavedSearchOutput): HarmonicSa savedSearchId, savedSearchUrn, name, - isPrivate: asBoolean(raw.is_private), + isPrivate: toBooleanOrNull(raw.is_private), savedSearchType: 'PERSONS', userSavedSearchType: requireUserSavedSearchType(raw.user_saved_search_type), creatorUrn: requireUserUrn(raw.creator), diff --git a/apps/sim/tools/incidentio/utils.ts b/apps/sim/tools/incidentio/utils.ts index 265f9d6921a..a09dc60e3fd 100644 --- a/apps/sim/tools/incidentio/utils.ts +++ b/apps/sim/tools/incidentio/utils.ts @@ -1,4 +1,5 @@ import { getErrorMessage } from '@sim/utils/errors' +import { toArray } from '@sim/utils/object' import type { Workflow } from '@/tools/incidentio/types' function toStringValue(value: unknown): string { @@ -17,10 +18,6 @@ function toBooleanValue(value: unknown): boolean { return value === true } -function toArrayValue(value: unknown): T[] { - return Array.isArray(value) ? (value as T[]) : [] -} - export function parseIncidentioJsonParam( jsonString: string | undefined, paramName: string, @@ -51,14 +48,14 @@ export function mapIncidentioWorkflow(workflow: Record): Workfl id: toStringValue(workflow.id), name: toStringValue(workflow.name), trigger: toStringValue(workflow.trigger), - once_for: toArrayValue(workflow.once_for), + once_for: toArray(workflow.once_for), version: toNumberValue(workflow.version), - expressions: toArrayValue(workflow.expressions), - condition_groups: toArrayValue(workflow.condition_groups), - steps: toArrayValue(workflow.steps), + expressions: toArray(workflow.expressions), + condition_groups: toArray(workflow.condition_groups), + steps: toArray(workflow.steps), include_private_incidents: toBooleanValue(workflow.include_private_incidents), include_private_escalations: toBooleanValue(workflow.include_private_escalations), - runs_on_incident_modes: toArrayValue(workflow.runs_on_incident_modes), + runs_on_incident_modes: toArray(workflow.runs_on_incident_modes), continue_on_step_error: toBooleanValue(workflow.continue_on_step_error), runs_on_incidents: toStringValue(workflow.runs_on_incidents) as Workflow['runs_on_incidents'], state: toStringValue(workflow.state) as Workflow['state'], diff --git a/apps/sim/tools/instantly/utils.ts b/apps/sim/tools/instantly/utils.ts index 511b64cf823..f692ca9d868 100644 --- a/apps/sim/tools/instantly/utils.ts +++ b/apps/sim/tools/instantly/utils.ts @@ -1,3 +1,4 @@ +import { toBooleanOrNull, toStringOrNull } from '@sim/utils/coerce' import { filterUndefined, toRecord, toRecordOrNull } from '@sim/utils/object' import type { InstantlyCampaign, @@ -62,31 +63,31 @@ export function getItems(value: unknown): JsonRecord[] { export function getNextStartingAfter(value: unknown): string | null { const data = toRecord(value) - return asString(data.next_starting_after) + return toStringOrNull(data.next_starting_after) } export function mapLead(value: unknown): InstantlyLead { const lead = toRecord(value) return { - id: asString(lead.id), - timestamp_created: asString(lead.timestamp_created), - timestamp_updated: asString(lead.timestamp_updated), - organization: asString(lead.organization), - campaign: asString(lead.campaign), + id: toStringOrNull(lead.id), + timestamp_created: toStringOrNull(lead.timestamp_created), + timestamp_updated: toStringOrNull(lead.timestamp_updated), + organization: toStringOrNull(lead.organization), + campaign: toStringOrNull(lead.campaign), status: asNumber(lead.status), - email: asString(lead.email), - personalization: asString(lead.personalization), - website: asString(lead.website), - last_name: asString(lead.last_name), - first_name: asString(lead.first_name), - company_name: asString(lead.company_name), - job_title: asString(lead.job_title), - phone: asString(lead.phone), + email: toStringOrNull(lead.email), + personalization: toStringOrNull(lead.personalization), + website: toStringOrNull(lead.website), + last_name: toStringOrNull(lead.last_name), + first_name: toStringOrNull(lead.first_name), + company_name: toStringOrNull(lead.company_name), + job_title: toStringOrNull(lead.job_title), + phone: toStringOrNull(lead.phone), email_open_count: asNumber(lead.email_open_count), email_reply_count: asNumber(lead.email_reply_count), email_click_count: asNumber(lead.email_click_count), - company_domain: asString(lead.company_domain), + company_domain: toStringOrNull(lead.company_domain), payload: toRecordOrNull(lead.payload), lt_interest_status: asNumber(lead.lt_interest_status), } @@ -96,18 +97,18 @@ export function mapCampaign(value: unknown): InstantlyCampaign { const campaign = toRecord(value) return { - id: asString(campaign.id), - name: asString(campaign.name), + id: toStringOrNull(campaign.id), + name: toStringOrNull(campaign.name), pl_value: asNumber(campaign.pl_value), status: asNumber(campaign.status), - is_evergreen: asBoolean(campaign.is_evergreen), - timestamp_created: asString(campaign.timestamp_created), - timestamp_updated: asString(campaign.timestamp_updated), + is_evergreen: toBooleanOrNull(campaign.is_evergreen), + timestamp_created: toStringOrNull(campaign.timestamp_created), + timestamp_updated: toStringOrNull(campaign.timestamp_updated), email_gap: asNumber(campaign.email_gap), daily_limit: asNumber(campaign.daily_limit), daily_max_leads: asNumber(campaign.daily_max_leads), - open_tracking: asBoolean(campaign.open_tracking), - stop_on_reply: asBoolean(campaign.stop_on_reply), + open_tracking: toBooleanOrNull(campaign.open_tracking), + stop_on_reply: toBooleanOrNull(campaign.stop_on_reply), sequences: Array.isArray(campaign.sequences) ? campaign.sequences : [], campaign_schedule: toRecordOrNull(campaign.campaign_schedule), } @@ -118,33 +119,33 @@ export function mapEmail(value: unknown): InstantlyEmail { const body = toRecord(email.body) return { - id: asString(email.id), - timestamp_created: asString(email.timestamp_created), - timestamp_email: asString(email.timestamp_email), - message_id: asString(email.message_id), - subject: asString(email.subject), - from_address_email: asString(email.from_address_email), - to_address_email_list: asString(email.to_address_email_list), - cc_address_email_list: asString(email.cc_address_email_list), - bcc_address_email_list: asString(email.bcc_address_email_list), - reply_to: asString(email.reply_to), + id: toStringOrNull(email.id), + timestamp_created: toStringOrNull(email.timestamp_created), + timestamp_email: toStringOrNull(email.timestamp_email), + message_id: toStringOrNull(email.message_id), + subject: toStringOrNull(email.subject), + from_address_email: toStringOrNull(email.from_address_email), + to_address_email_list: toStringOrNull(email.to_address_email_list), + cc_address_email_list: toStringOrNull(email.cc_address_email_list), + bcc_address_email_list: toStringOrNull(email.bcc_address_email_list), + reply_to: toStringOrNull(email.reply_to), body: { - text: asString(body.text), - html: asString(body.html), + text: toStringOrNull(body.text), + html: toStringOrNull(body.html), }, - organization_id: asString(email.organization_id), - campaign_id: asString(email.campaign_id), - subsequence_id: asString(email.subsequence_id), - list_id: asString(email.list_id), - lead: asString(email.lead), - lead_id: asString(email.lead_id), - eaccount: asString(email.eaccount), + organization_id: toStringOrNull(email.organization_id), + campaign_id: toStringOrNull(email.campaign_id), + subsequence_id: toStringOrNull(email.subsequence_id), + list_id: toStringOrNull(email.list_id), + lead: toStringOrNull(email.lead), + lead_id: toStringOrNull(email.lead_id), + eaccount: toStringOrNull(email.eaccount), ue_type: asNumber(email.ue_type), is_unread: asNumber(email.is_unread), is_auto_reply: asNumber(email.is_auto_reply), i_status: asNumber(email.i_status), - thread_id: asString(email.thread_id), - content_preview: asString(email.content_preview), + thread_id: toStringOrNull(email.thread_id), + content_preview: toStringOrNull(email.content_preview), } } @@ -152,12 +153,12 @@ export function mapLeadList(value: unknown): InstantlyLeadList { const leadList = toRecord(value) return { - id: asString(leadList.id), - organization_id: asString(leadList.organization_id), - has_enrichment_task: asBoolean(leadList.has_enrichment_task), - owned_by: asString(leadList.owned_by), - name: asString(leadList.name), - timestamp_created: asString(leadList.timestamp_created), + id: toStringOrNull(leadList.id), + organization_id: toStringOrNull(leadList.organization_id), + has_enrichment_task: toBooleanOrNull(leadList.has_enrichment_task), + owned_by: toStringOrNull(leadList.owned_by), + name: toStringOrNull(leadList.name), + timestamp_created: toStringOrNull(leadList.timestamp_created), } } @@ -355,14 +356,6 @@ function extractInstantlyError(value: unknown, fallback: string): string { return fallback } -function asString(value: unknown): string | null { - return typeof value === 'string' ? value : null -} - function asNumber(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) ? value : null } - -function asBoolean(value: unknown): boolean | null { - return typeof value === 'boolean' ? value : null -} diff --git a/apps/sim/tools/mintlify/create_assistant_message.ts b/apps/sim/tools/mintlify/create_assistant_message.ts index 3a25d17b96f..78b0e676ce2 100644 --- a/apps/sim/tools/mintlify/create_assistant_message.ts +++ b/apps/sim/tools/mintlify/create_assistant_message.ts @@ -1,3 +1,4 @@ +import { toStringOrNull } from '@sim/utils/coerce' import { generateId } from '@sim/utils/id' import type { MintlifyAssistantSource, @@ -8,7 +9,6 @@ import { MINTLIFY_API_BASE, mintlifyHeaders, pathSegment, - toNullableString, toObjectArray, toStringArray, } from '@/tools/mintlify/utils' @@ -49,16 +49,16 @@ function parseAssistantStream(body: string): AssistantStreamResult { break case 'source-url': result.sources.push({ - sourceId: toNullableString(event.sourceId), - url: toNullableString(event.url), - title: toNullableString(event.title), + sourceId: toStringOrNull(event.sourceId), + url: toStringOrNull(event.url), + title: toStringOrNull(event.title), }) break case 'error': - result.errorText = toNullableString(event.errorText) + result.errorText = toStringOrNull(event.errorText) break case 'finish': - result.threadId = toNullableString(event.threadId) + result.threadId = toStringOrNull(event.threadId) break default: break diff --git a/apps/sim/tools/mintlify/detect_ai_prose.ts b/apps/sim/tools/mintlify/detect_ai_prose.ts index 1e3cd307225..98dc60ef92a 100644 --- a/apps/sim/tools/mintlify/detect_ai_prose.ts +++ b/apps/sim/tools/mintlify/detect_ai_prose.ts @@ -1,3 +1,4 @@ +import { toStringOrNull } from '@sim/utils/coerce' import type { MintlifyDeslopRewrite, MintlifyDeslopWindow, @@ -10,7 +11,6 @@ import { pathSegment, readMintlifyJson, toNullableNumber, - toNullableString, } from '@/tools/mintlify/utils' import type { ToolConfig } from '@/tools/types' @@ -19,8 +19,8 @@ function toRewrites(value: unknown): MintlifyDeslopRewrite[] { return value.map((entry) => { const rewrite = (entry ?? {}) as Record return { - text: toNullableString(rewrite.text) ?? '', - rationale: toNullableString(rewrite.rationale) ?? '', + text: toStringOrNull(rewrite.text) ?? '', + rationale: toStringOrNull(rewrite.rationale) ?? '', } }) } @@ -31,8 +31,8 @@ function toWindows(value: unknown): MintlifyDeslopWindow[] { const window = (entry ?? {}) as Record const confidence = window.confidence return { - text: toNullableString(window.text) ?? '', - label: toNullableString(window.label) ?? '', + text: toStringOrNull(window.text) ?? '', + label: toStringOrNull(window.label) ?? '', aiAssistanceScore: toNullableNumber(window.aiAssistanceScore), confidence: typeof confidence === 'string' || typeof confidence === 'number' ? confidence : null, @@ -93,9 +93,9 @@ export const mintlifyDetectAiProseTool: ToolConfig< return { success: true, output: { - path: toNullableString(data.path), - skipped: toNullableString(data.skipped), - predictionShort: toNullableString(data.predictionShort), + path: toStringOrNull(data.path), + skipped: toStringOrNull(data.skipped), + predictionShort: toStringOrNull(data.predictionShort), fractionAi: toNullableNumber(data.fractionAi), fractionAiAssisted: toNullableNumber(data.fractionAiAssisted), fractionHuman: toNullableNumber(data.fractionHuman), diff --git a/apps/sim/tools/mintlify/get_assistant_conversations.ts b/apps/sim/tools/mintlify/get_assistant_conversations.ts index fd84528955b..afbf748cbbc 100644 --- a/apps/sim/tools/mintlify/get_assistant_conversations.ts +++ b/apps/sim/tools/mintlify/get_assistant_conversations.ts @@ -1,3 +1,4 @@ +import { toStringOrNull } from '@sim/utils/coerce' import type { MintlifyConversation, MintlifyGetAssistantConversationsParams, @@ -9,7 +10,6 @@ import { mintlifyHeaders, pathSegment, readMintlifyJson, - toNullableString, } from '@/tools/mintlify/utils' import type { ToolConfig } from '@/tools/types' @@ -20,20 +20,20 @@ function toConversations(value: unknown): MintlifyConversation[] { const sources = Array.isArray(conversation.sources) ? conversation.sources : [] return { - id: toNullableString(conversation.id), - timestamp: toNullableString(conversation.timestamp), - query: toNullableString(conversation.query), - response: toNullableString(conversation.response), + id: toStringOrNull(conversation.id), + timestamp: toStringOrNull(conversation.timestamp), + query: toStringOrNull(conversation.query), + response: toStringOrNull(conversation.response), sources: sources.map((entry) => { const source = (entry ?? {}) as Record return { - title: toNullableString(source.title), - url: toNullableString(source.url), + title: toStringOrNull(source.title), + url: toStringOrNull(source.url), } }), - resolutionStatus: toNullableString(conversation.resolutionStatus), - queryCategory: toNullableString(conversation.queryCategory), - pageUrl: toNullableString(conversation.pageUrl), + resolutionStatus: toStringOrNull(conversation.resolutionStatus), + queryCategory: toStringOrNull(conversation.queryCategory), + pageUrl: toStringOrNull(conversation.pageUrl), } }) } @@ -109,7 +109,7 @@ export const mintlifyGetAssistantConversationsTool: ToolConfig< success: true, output: { conversations: toConversations(data.conversations), - nextCursor: toNullableString(data.nextCursor), + nextCursor: toStringOrNull(data.nextCursor), hasMore: data.hasMore === true, }, } diff --git a/apps/sim/tools/mintlify/get_feedback.ts b/apps/sim/tools/mintlify/get_feedback.ts index 071794f1698..097fdbe2a28 100644 --- a/apps/sim/tools/mintlify/get_feedback.ts +++ b/apps/sim/tools/mintlify/get_feedback.ts @@ -1,3 +1,4 @@ +import { toStringOrNull } from '@sim/utils/coerce' import type { MintlifyFeedbackEntry, MintlifyGetFeedbackParams, @@ -9,7 +10,6 @@ import { mintlifyHeaders, pathSegment, readMintlifyJson, - toNullableString, } from '@/tools/mintlify/utils' import type { ToolConfig } from '@/tools/types' @@ -18,17 +18,17 @@ function toFeedbackEntries(value: unknown): MintlifyFeedbackEntry[] { return value.map((item) => { const entry = (item ?? {}) as Record return { - id: toNullableString(entry.id), - path: toNullableString(entry.path), - comment: toNullableString(entry.comment), - createdAt: toNullableString(entry.createdAt), - source: toNullableString(entry.source), - status: toNullableString(entry.status), + id: toStringOrNull(entry.id), + path: toStringOrNull(entry.path), + comment: toStringOrNull(entry.comment), + createdAt: toStringOrNull(entry.createdAt), + source: toStringOrNull(entry.source), + status: toStringOrNull(entry.status), helpful: typeof entry.helpful === 'boolean' ? entry.helpful : null, - contact: toNullableString(entry.contact), - code: toNullableString(entry.code), - filename: toNullableString(entry.filename), - lang: toNullableString(entry.lang), + contact: toStringOrNull(entry.contact), + code: toStringOrNull(entry.code), + filename: toStringOrNull(entry.filename), + lang: toStringOrNull(entry.lang), } }) } @@ -119,7 +119,7 @@ export const mintlifyGetFeedbackTool: ToolConfig< success: true, output: { feedback: toFeedbackEntries(data.feedback), - nextCursor: toNullableString(data.nextCursor), + nextCursor: toStringOrNull(data.nextCursor), hasMore: data.hasMore === true, }, } diff --git a/apps/sim/tools/mintlify/get_feedback_by_page.ts b/apps/sim/tools/mintlify/get_feedback_by_page.ts index 7dc77789e92..87b1d5bcae5 100644 --- a/apps/sim/tools/mintlify/get_feedback_by_page.ts +++ b/apps/sim/tools/mintlify/get_feedback_by_page.ts @@ -1,3 +1,4 @@ +import { toStringOrNull } from '@sim/utils/coerce' import type { MintlifyFeedbackPageEntry, MintlifyGetFeedbackByPageParams, @@ -10,7 +11,6 @@ import { pathSegment, readMintlifyJson, toNullableNumber, - toNullableString, } from '@/tools/mintlify/utils' import type { ToolConfig } from '@/tools/types' @@ -19,7 +19,7 @@ function toPageEntries(value: unknown): MintlifyFeedbackPageEntry[] { return value.map((item) => { const entry = (item ?? {}) as Record return { - path: toNullableString(entry.path), + path: toStringOrNull(entry.path), thumbsUp: toNullableNumber(entry.thumbsUp), thumbsDown: toNullableNumber(entry.thumbsDown), code: toNullableNumber(entry.code), diff --git a/apps/sim/tools/mintlify/get_page_content.ts b/apps/sim/tools/mintlify/get_page_content.ts index bddb039ef35..209bd6a54f2 100644 --- a/apps/sim/tools/mintlify/get_page_content.ts +++ b/apps/sim/tools/mintlify/get_page_content.ts @@ -1,3 +1,4 @@ +import { toStringOrNull } from '@sim/utils/coerce' import type { MintlifyGetPageContentParams, MintlifyGetPageContentResponse, @@ -7,7 +8,6 @@ import { mintlifyHeaders, pathSegment, readMintlifyJson, - toNullableString, toStringArray, } from '@/tools/mintlify/utils' import type { ToolConfig } from '@/tools/types' @@ -70,8 +70,8 @@ export const mintlifyGetPageContentTool: ToolConfig< return { success: true, output: { - path: toNullableString(data.path), - content: toNullableString(data.content), + path: toStringOrNull(data.path), + content: toStringOrNull(data.content), }, } }, diff --git a/apps/sim/tools/mintlify/get_searches.ts b/apps/sim/tools/mintlify/get_searches.ts index 1260d43647f..68b3d40e529 100644 --- a/apps/sim/tools/mintlify/get_searches.ts +++ b/apps/sim/tools/mintlify/get_searches.ts @@ -1,3 +1,4 @@ +import { toStringOrNull } from '@sim/utils/coerce' import type { MintlifyGetSearchesParams, MintlifyGetSearchesResponse, @@ -10,7 +11,6 @@ import { pathSegment, readMintlifyJson, toNullableNumber, - toNullableString, } from '@/tools/mintlify/utils' import type { ToolConfig } from '@/tools/types' @@ -19,11 +19,11 @@ function toSearchRows(value: unknown): MintlifySearchQueryRow[] { return value.map((item) => { const row = (item ?? {}) as Record return { - searchQuery: toNullableString(row.searchQuery), + searchQuery: toStringOrNull(row.searchQuery), hits: toNullableNumber(row.hits), ctr: toNullableNumber(row.ctr), - topClickedPage: toNullableString(row.topClickedPage), - lastSearchedAt: toNullableString(row.lastSearchedAt), + topClickedPage: toStringOrNull(row.topClickedPage), + lastSearchedAt: toStringOrNull(row.lastSearchedAt), } }) } @@ -100,7 +100,7 @@ export const mintlifyGetSearchesTool: ToolConfig< output: { searches: toSearchRows(data.searches), totalSearches: toNullableNumber(data.totalSearches), - nextCursor: toNullableString(data.nextCursor), + nextCursor: toStringOrNull(data.nextCursor), }, } }, diff --git a/apps/sim/tools/mintlify/get_update_status.ts b/apps/sim/tools/mintlify/get_update_status.ts index c3a2281360b..09efb52f8bb 100644 --- a/apps/sim/tools/mintlify/get_update_status.ts +++ b/apps/sim/tools/mintlify/get_update_status.ts @@ -1,3 +1,4 @@ +import { toStringOrNull } from '@sim/utils/coerce' import type { MintlifyGetUpdateStatusParams, MintlifyGetUpdateStatusResponse, @@ -10,7 +11,6 @@ import { pathSegment, readMintlifyJson, toNullableNumber, - toNullableString, } from '@/tools/mintlify/utils' import type { ToolConfig } from '@/tools/types' @@ -24,8 +24,8 @@ function toAuthor(value: unknown): MintlifyUpdateAuthor | null { if (!value || typeof value !== 'object') return null const author = value as Record return { - name: toNullableString(author.name), - avatarUrl: toNullableString(author.avatarUrl), + name: toStringOrNull(author.name), + avatarUrl: toStringOrNull(author.avatarUrl), githubUserId: toNullableNumber(author.githubUserId), } } @@ -36,9 +36,9 @@ function toCommit(value: unknown): MintlifyUpdateCommit | null { const filesChanged = commit.filesChanged as Record | undefined return { - sha: toNullableString(commit.sha), - ref: toNullableString(commit.ref), - message: toNullableString(commit.message), + sha: toStringOrNull(commit.sha), + ref: toStringOrNull(commit.ref), + message: toStringOrNull(commit.message), filesChanged: filesChanged ? { added: toStringList(filesChanged.added), @@ -87,20 +87,20 @@ export const mintlifyGetUpdateStatusTool: ToolConfig< return { success: true, output: { - id: toNullableString(data._id), - projectId: toNullableString(data.projectId), - createdAt: toNullableString(data.createdAt), - endedAt: toNullableString(data.endedAt), - status: toNullableString(data.status), - summary: toNullableString(data.summary), + id: toStringOrNull(data._id), + projectId: toStringOrNull(data.projectId), + createdAt: toStringOrNull(data.createdAt), + endedAt: toStringOrNull(data.endedAt), + status: toStringOrNull(data.status), + summary: toStringOrNull(data.summary), logs: toStringList(data.logs), - subdomain: toNullableString(data.subdomain), - screenshot: toNullableString(data.screenshot), - screenshotLight: toNullableString(data.screenshotLight), - screenshotDark: toNullableString(data.screenshotDark), + subdomain: toStringOrNull(data.subdomain), + screenshot: toStringOrNull(data.screenshot), + screenshotLight: toStringOrNull(data.screenshotLight), + screenshotDark: toStringOrNull(data.screenshotDark), author: toAuthor(data.author), commit: toCommit(data.commit), - source: toNullableString(data.source), + source: toStringOrNull(data.source), }, } }, diff --git a/apps/sim/tools/mintlify/search.ts b/apps/sim/tools/mintlify/search.ts index 29cd33f48e6..9706d7c9d49 100644 --- a/apps/sim/tools/mintlify/search.ts +++ b/apps/sim/tools/mintlify/search.ts @@ -1,3 +1,4 @@ +import { toStringOrNull } from '@sim/utils/coerce' import type { MintlifySearchParams, MintlifySearchResponse, @@ -8,7 +9,6 @@ import { mintlifyHeaders, pathSegment, readMintlifyJson, - toNullableString, toStringArray, } from '@/tools/mintlify/utils' import type { ToolConfig } from '@/tools/types' @@ -108,8 +108,8 @@ export const mintlifySearchTool: ToolConfig { const row = (entry ?? {}) as Record return { - content: toNullableString(row.content), - path: toNullableString(row.path), + content: toStringOrNull(row.content), + path: toStringOrNull(row.path), metadata: row.metadata && typeof row.metadata === 'object' ? (row.metadata as Record) diff --git a/apps/sim/tools/mintlify/trigger_automation.ts b/apps/sim/tools/mintlify/trigger_automation.ts index 25eaa762552..d68c76a403e 100644 --- a/apps/sim/tools/mintlify/trigger_automation.ts +++ b/apps/sim/tools/mintlify/trigger_automation.ts @@ -1,3 +1,4 @@ +import { toStringOrNull } from '@sim/utils/coerce' import type { MintlifyTriggerAutomationParams, MintlifyTriggerAutomationResponse, @@ -7,7 +8,6 @@ import { mintlifyHeaders, pathSegment, readMintlifyJson, - toNullableString, } from '@/tools/mintlify/utils' import type { ToolConfig } from '@/tools/types' @@ -56,9 +56,9 @@ export const mintlifyTriggerAutomationTool: ToolConfig< return { success: true, output: { - schemaId: toNullableString(data.schemaId), - instanceId: toNullableString(data.instanceId), - jobId: toNullableString(data.jobId), + schemaId: toStringOrNull(data.schemaId), + instanceId: toStringOrNull(data.instanceId), + jobId: toStringOrNull(data.jobId), }, } }, diff --git a/apps/sim/tools/mintlify/trigger_preview.ts b/apps/sim/tools/mintlify/trigger_preview.ts index 245bf447e68..2659069282a 100644 --- a/apps/sim/tools/mintlify/trigger_preview.ts +++ b/apps/sim/tools/mintlify/trigger_preview.ts @@ -1,3 +1,4 @@ +import { toStringOrNull } from '@sim/utils/coerce' import type { MintlifyTriggerPreviewParams, MintlifyTriggerPreviewResponse, @@ -7,7 +8,6 @@ import { mintlifyHeaders, pathSegment, readMintlifyJson, - toNullableString, } from '@/tools/mintlify/utils' import type { ToolConfig } from '@/tools/types' @@ -55,8 +55,8 @@ export const mintlifyTriggerPreviewTool: ToolConfig< return { success: true, output: { - statusId: toNullableString(data.statusId), - previewUrl: toNullableString(data.previewUrl), + statusId: toStringOrNull(data.statusId), + previewUrl: toStringOrNull(data.previewUrl), }, } }, diff --git a/apps/sim/tools/mintlify/trigger_update.ts b/apps/sim/tools/mintlify/trigger_update.ts index 12b63f98863..c4b74fad97f 100644 --- a/apps/sim/tools/mintlify/trigger_update.ts +++ b/apps/sim/tools/mintlify/trigger_update.ts @@ -1,3 +1,4 @@ +import { toStringOrNull } from '@sim/utils/coerce' import type { MintlifyTriggerUpdateParams, MintlifyTriggerUpdateResponse, @@ -7,7 +8,6 @@ import { mintlifyHeaders, pathSegment, readMintlifyJson, - toNullableString, } from '@/tools/mintlify/utils' import type { ToolConfig } from '@/tools/types' @@ -48,7 +48,7 @@ export const mintlifyTriggerUpdateTool: ToolConfig< return { success: true, output: { - statusId: toNullableString(data.statusId), + statusId: toStringOrNull(data.statusId), }, } }, diff --git a/apps/sim/tools/mintlify/utils.ts b/apps/sim/tools/mintlify/utils.ts index 696d56f5c09..9150304e3f8 100644 --- a/apps/sim/tools/mintlify/utils.ts +++ b/apps/sim/tools/mintlify/utils.ts @@ -1,3 +1,4 @@ +import { toStringOrNull } from '@sim/utils/coerce' import type { MintlifyAgentJobOutput, MintlifyTrafficRow, @@ -109,11 +110,6 @@ export function toNullableNumber(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) ? value : null } -/** Normalizes a nullable string field from a Mintlify response. */ -export function toNullableString(value: unknown): string | null { - return typeof value === 'string' ? value : null -} - /** * Maps the `AgentJob` payload returned identically by create-job, get-job, and * send-message. @@ -122,18 +118,18 @@ export function toAgentJobOutput(data: Record): MintlifyAgentJo const source = data.source as Record | undefined return { - id: toNullableString(data.id), - status: toNullableString(data.status), + id: toStringOrNull(data.id), + status: toStringOrNull(data.status), source: source ? { - repository: toNullableString(source.repository), - ref: toNullableString(source.ref), + repository: toStringOrNull(source.repository), + ref: toStringOrNull(source.ref), } : null, - model: toNullableString(data.model), - prLink: toNullableString(data.prLink), - createdAt: toNullableString(data.createdAt), - archivedAt: toNullableString(data.archivedAt), + model: toStringOrNull(data.model), + prLink: toStringOrNull(data.prLink), + createdAt: toStringOrNull(data.createdAt), + archivedAt: toStringOrNull(data.archivedAt), } } @@ -154,7 +150,7 @@ export function toTrafficRows(value: unknown): MintlifyTrafficRow[] { return value.map((item) => { const row = (item ?? {}) as Record return { - path: toNullableString(row.path), + path: toStringOrNull(row.path), human: toNullableNumber(row.human), ai: toNullableNumber(row.ai), total: toNullableNumber(row.total), diff --git a/apps/sim/tools/rabbitmq/get_overview.ts b/apps/sim/tools/rabbitmq/get_overview.ts index 63653e9138d..77fa4cbc780 100644 --- a/apps/sim/tools/rabbitmq/get_overview.ts +++ b/apps/sim/tools/rabbitmq/get_overview.ts @@ -1,3 +1,4 @@ +import { toStringOrNull } from '@sim/utils/coerce' import { toRecord } from '@sim/utils/object' import type { RabbitmqGetOverviewParams, RabbitmqGetOverviewResponse } from '@/tools/rabbitmq/types' import { @@ -20,10 +21,6 @@ const EMPTY_OVERVIEW = { messageStats: {}, } as const -function asStringOrNull(value: unknown): string | null { - return typeof value === 'string' ? value : null -} - export const rabbitmqGetOverviewTool: ToolConfig< RabbitmqGetOverviewParams, RabbitmqGetOverviewResponse @@ -54,12 +51,12 @@ export const rabbitmqGetOverviewTool: ToolConfig< return { success: true, output: { - rabbitmqVersion: asStringOrNull(data?.rabbitmq_version), - productName: asStringOrNull(data?.product_name), - productVersion: asStringOrNull(data?.product_version), - erlangVersion: asStringOrNull(data?.erlang_version), - clusterName: asStringOrNull(data?.cluster_name), - node: asStringOrNull(data?.node), + rabbitmqVersion: toStringOrNull(data?.rabbitmq_version), + productName: toStringOrNull(data?.product_name), + productVersion: toStringOrNull(data?.product_version), + erlangVersion: toStringOrNull(data?.erlang_version), + clusterName: toStringOrNull(data?.cluster_name), + node: toStringOrNull(data?.node), objectTotals: toRecord(data?.object_totals), queueTotals: toRecord(data?.queue_totals), messageStats: toRecord(data?.message_stats), diff --git a/apps/sim/tools/rabbitmq/utils.ts b/apps/sim/tools/rabbitmq/utils.ts index a834b7e03b0..10d31a842b1 100644 --- a/apps/sim/tools/rabbitmq/utils.ts +++ b/apps/sim/tools/rabbitmq/utils.ts @@ -1,4 +1,5 @@ import { isLoopbackIp } from '@sim/security/ssrf' +import { toBooleanOrNull, toNumberOrNull, toStringOrNull } from '@sim/utils/coerce' import { toRecord } from '@sim/utils/object' import type { RabbitmqBinding, @@ -283,18 +284,6 @@ export function unwrapPaginated(data: unknown): PaginatedResult { } } -function asNumberOrNull(value: unknown): number | null { - return typeof value === 'number' ? value : null -} - -function asStringOrNull(value: unknown): string | null { - return typeof value === 'string' ? value : null -} - -function asBooleanOrNull(value: unknown): boolean | null { - return typeof value === 'boolean' ? value : null -} - /** * Fields requested from list endpoints via the `columns` query parameter. A full queue record * carries per-queue statistics, consumer details, and garbage-collection blobs that this @@ -338,19 +327,19 @@ export function projectQueue(raw: unknown): RabbitmqQueue { return { name: String(queue.name ?? ''), vhost: String(queue.vhost ?? ''), - type: asStringOrNull(queue.type), - state: asStringOrNull(queue.state), - durable: asBooleanOrNull(queue.durable), - autoDelete: asBooleanOrNull(queue.auto_delete), - exclusive: asBooleanOrNull(queue.exclusive), - node: asStringOrNull(queue.node), - policy: asStringOrNull(queue.policy), + type: toStringOrNull(queue.type), + state: toStringOrNull(queue.state), + durable: toBooleanOrNull(queue.durable), + autoDelete: toBooleanOrNull(queue.auto_delete), + exclusive: toBooleanOrNull(queue.exclusive), + node: toStringOrNull(queue.node), + policy: toStringOrNull(queue.policy), arguments: toRecord(queue.arguments), - messages: asNumberOrNull(queue.messages), - messagesReady: asNumberOrNull(queue.messages_ready), - messagesUnacknowledged: asNumberOrNull(queue.messages_unacknowledged), - consumers: asNumberOrNull(queue.consumers), - memory: asNumberOrNull(queue.memory), + messages: toNumberOrNull(queue.messages), + messagesReady: toNumberOrNull(queue.messages_ready), + messagesUnacknowledged: toNumberOrNull(queue.messages_unacknowledged), + consumers: toNumberOrNull(queue.consumers), + memory: toNumberOrNull(queue.memory), } } @@ -521,13 +510,13 @@ export function projectVhost(raw: unknown): RabbitmqVhost { const vhost = toRecord(raw) return { name: String(vhost.name ?? ''), - description: asStringOrNull(vhost.description), + description: toStringOrNull(vhost.description), tags: Array.isArray(vhost.tags) ? (vhost.tags as string[]) : [], - defaultQueueType: asStringOrNull(vhost.default_queue_type), + defaultQueueType: toStringOrNull(vhost.default_queue_type), tracing: vhost.tracing === true, - messages: asNumberOrNull(vhost.messages), - messagesReady: asNumberOrNull(vhost.messages_ready), - messagesUnacknowledged: asNumberOrNull(vhost.messages_unacknowledged), + messages: toNumberOrNull(vhost.messages), + messagesReady: toNumberOrNull(vhost.messages_ready), + messagesUnacknowledged: toNumberOrNull(vhost.messages_unacknowledged), clusterState: toRecord(vhost.cluster_state), } } @@ -538,13 +527,13 @@ export function projectConnection(raw: unknown): RabbitmqConnection { name: String(connection.name ?? ''), user: String(connection.user ?? ''), vhost: String(connection.vhost ?? ''), - state: asStringOrNull(connection.state), - protocol: asStringOrNull(connection.protocol), - node: asStringOrNull(connection.node), - channels: asNumberOrNull(connection.channels), - peerHost: asStringOrNull(connection.peer_host), - peerPort: asNumberOrNull(connection.peer_port), - connectedAt: asNumberOrNull(connection.connected_at), + state: toStringOrNull(connection.state), + protocol: toStringOrNull(connection.protocol), + node: toStringOrNull(connection.node), + channels: toNumberOrNull(connection.channels), + peerHost: toStringOrNull(connection.peer_host), + peerPort: toNumberOrNull(connection.peer_port), + connectedAt: toNumberOrNull(connection.connected_at), ssl: connection.ssl === true, } } @@ -554,16 +543,16 @@ export function projectChannel(raw: unknown): RabbitmqChannel { const connectionDetails = toRecord(channel.connection_details) return { name: String(channel.name ?? ''), - number: asNumberOrNull(channel.number), + number: toNumberOrNull(channel.number), user: String(channel.user ?? ''), vhost: String(channel.vhost ?? ''), - node: asStringOrNull(channel.node), - state: asStringOrNull(channel.state), - consumerCount: asNumberOrNull(channel.consumer_count), - prefetchCount: asNumberOrNull(channel.prefetch_count), - messagesUnacknowledged: asNumberOrNull(channel.messages_unacknowledged), + node: toStringOrNull(channel.node), + state: toStringOrNull(channel.state), + consumerCount: toNumberOrNull(channel.consumer_count), + prefetchCount: toNumberOrNull(channel.prefetch_count), + messagesUnacknowledged: toNumberOrNull(channel.messages_unacknowledged), confirm: channel.confirm === true, - connectionName: asStringOrNull(connectionDetails.name), + connectionName: toStringOrNull(connectionDetails.name), } } @@ -577,11 +566,11 @@ export function projectConsumer(raw: unknown): RabbitmqConsumer { vhost: String(queue.vhost ?? ''), ackRequired: consumer.ack_required === true, active: consumer.active === true, - activityStatus: asStringOrNull(consumer.activity_status), + activityStatus: toStringOrNull(consumer.activity_status), exclusive: consumer.exclusive === true, - prefetchCount: asNumberOrNull(consumer.prefetch_count), - channelName: asStringOrNull(channelDetails.name), - connectionName: asStringOrNull(channelDetails.connection_name), + prefetchCount: toNumberOrNull(consumer.prefetch_count), + channelName: toStringOrNull(channelDetails.name), + connectionName: toStringOrNull(channelDetails.connection_name), } } @@ -589,19 +578,19 @@ export function projectNode(raw: unknown): RabbitmqNode { const node = toRecord(raw) return { name: String(node.name ?? ''), - type: asStringOrNull(node.type), + type: toStringOrNull(node.type), running: node.running === true, - memUsed: asNumberOrNull(node.mem_used), - memLimit: asNumberOrNull(node.mem_limit), + memUsed: toNumberOrNull(node.mem_used), + memLimit: toNumberOrNull(node.mem_limit), memAlarm: node.mem_alarm === true, - diskFree: asNumberOrNull(node.disk_free), - diskFreeLimit: asNumberOrNull(node.disk_free_limit), + diskFree: toNumberOrNull(node.disk_free), + diskFreeLimit: toNumberOrNull(node.disk_free_limit), diskFreeAlarm: node.disk_free_alarm === true, - fdUsed: asNumberOrNull(node.fd_used), - fdTotal: asNumberOrNull(node.fd_total), - procUsed: asNumberOrNull(node.proc_used), - procTotal: asNumberOrNull(node.proc_total), - uptime: asNumberOrNull(node.uptime), + fdUsed: toNumberOrNull(node.fd_used), + fdTotal: toNumberOrNull(node.fd_total), + procUsed: toNumberOrNull(node.proc_used), + procTotal: toNumberOrNull(node.proc_total), + uptime: toNumberOrNull(node.uptime), partitions: Array.isArray(node.partitions) ? (node.partitions as string[]) : [], beingDrained: node.being_drained === true, } @@ -613,8 +602,8 @@ export function projectPolicy(raw: unknown): RabbitmqPolicy { name: String(policy.name ?? ''), vhost: String(policy.vhost ?? ''), pattern: String(policy.pattern ?? ''), - applyTo: asStringOrNull(policy['apply-to']), - priority: asNumberOrNull(policy.priority), + applyTo: toStringOrNull(policy['apply-to']), + priority: toNumberOrNull(policy.priority), definition: toRecord(policy.definition), } } diff --git a/apps/sim/tools/rocketlane/types.ts b/apps/sim/tools/rocketlane/types.ts index f638c5ccc3e..cce25ba2de5 100644 --- a/apps/sim/tools/rocketlane/types.ts +++ b/apps/sim/tools/rocketlane/types.ts @@ -1,4 +1,5 @@ -import { toRecordOrNull } from '@sim/utils/object' +import { toBooleanOrNull, toNumberOrNull, toStringOrNull } from '@sim/utils/coerce' +import { toArray, toRecordOrNull } from '@sim/utils/object' import type { OutputProperty, ToolResponse } from '@/tools/types' /** Base URL for the Rocketlane REST API (v1.0). */ @@ -46,22 +47,6 @@ export async function rocketlaneError(response: Response): Promise { type Raw = Record -function asString(value: unknown): string | null { - return typeof value === 'string' ? value : null -} - -function asNumber(value: unknown): number | null { - return typeof value === 'number' ? value : null -} - -function asBoolean(value: unknown): boolean | null { - return typeof value === 'boolean' ? value : null -} - -function asArray(value: unknown): unknown[] { - return Array.isArray(value) ? value : [] -} - // region Shared object shapes /** Compact user reference returned inside most Rocketlane resources. */ @@ -76,10 +61,10 @@ export function mapUserSummary(value: unknown): RocketlaneUserSummary | null { const raw = toRecordOrNull(value) if (!raw) return null return { - userId: asNumber(raw.userId), - firstName: asString(raw.firstName), - lastName: asString(raw.lastName), - emailId: asString(raw.emailId), + userId: toNumberOrNull(raw.userId), + firstName: toStringOrNull(raw.firstName), + lastName: toStringOrNull(raw.lastName), + emailId: toStringOrNull(raw.emailId), } } @@ -101,10 +86,10 @@ export interface RocketlanePagination { export function mapPagination(value: unknown): RocketlanePagination { const raw = toRecordOrNull(value) ?? {} return { - pageSize: asNumber(raw.pageSize), - hasMore: asBoolean(raw.hasMore), - totalRecordCount: asNumber(raw.totalRecordCount), - nextPageToken: asString(raw.nextPageToken), + pageSize: toNumberOrNull(raw.pageSize), + hasMore: toBooleanOrNull(raw.hasMore), + totalRecordCount: toNumberOrNull(raw.totalRecordCount), + nextPageToken: toStringOrNull(raw.nextPageToken), } } @@ -239,8 +224,8 @@ function mapTaskProjectRef(value: unknown): RocketlaneTaskProjectRef | null { const raw = toRecordOrNull(value) if (!raw) return null return { - projectId: asNumber(raw.projectId), - projectName: asString(raw.projectName), + projectId: toNumberOrNull(raw.projectId), + projectName: toStringOrNull(raw.projectName), } } @@ -248,8 +233,8 @@ function mapTaskPhaseRef(value: unknown): RocketlaneTaskPhaseRef | null { const raw = toRecordOrNull(value) if (!raw) return null return { - phaseId: asNumber(raw.phaseId), - phaseName: asString(raw.phaseName), + phaseId: toNumberOrNull(raw.phaseId), + phaseName: toStringOrNull(raw.phaseName), } } @@ -257,8 +242,8 @@ function mapTaskChoice(value: unknown): RocketlaneTaskChoice | null { const raw = toRecordOrNull(value) if (!raw) return null return { - value: asNumber(raw.value), - label: asString(raw.label), + value: toNumberOrNull(raw.value), + label: toStringOrNull(raw.label), } } @@ -266,16 +251,16 @@ function mapTaskRole(value: unknown): RocketlaneTaskRole | null { const raw = toRecordOrNull(value) if (!raw) return null return { - roleId: asNumber(raw.roleId), - roleName: asString(raw.roleName), + roleId: toNumberOrNull(raw.roleId), + roleName: toStringOrNull(raw.roleName), } } function mapTaskPlaceholder(value: unknown): RocketlaneTaskPlaceholder { const raw = toRecordOrNull(value) ?? {} return { - placeholderId: asNumber(raw.placeholderId), - placeholderName: asString(raw.placeholderName), + placeholderId: toNumberOrNull(raw.placeholderId), + placeholderName: toStringOrNull(raw.placeholderName), role: mapTaskRole(raw.role), } } @@ -284,10 +269,10 @@ function mapTaskAssignees(value: unknown): RocketlaneTaskAssignees | null { const raw = toRecordOrNull(value) if (!raw) return null return { - members: asArray(raw.members) + members: toArray(raw.members) .map(mapUserSummary) .filter((member): member is RocketlaneUserSummary => member !== null), - placeholders: asArray(raw.placeholders).map(mapTaskPlaceholder), + placeholders: toArray(raw.placeholders).map(mapTaskPlaceholder), } } @@ -295,7 +280,7 @@ function mapTaskFollowers(value: unknown): RocketlaneTaskFollowers | null { const raw = toRecordOrNull(value) if (!raw) return null return { - members: asArray(raw.members) + members: toArray(raw.members) .map(mapUserSummary) .filter((member): member is RocketlaneUserSummary => member !== null), } @@ -304,18 +289,18 @@ function mapTaskFollowers(value: unknown): RocketlaneTaskFollowers | null { function mapTaskLite(value: unknown): RocketlaneTaskLite { const raw = toRecordOrNull(value) ?? {} return { - taskId: asNumber(raw.taskId), - taskName: asString(raw.taskName), + taskId: toNumberOrNull(raw.taskId), + taskName: toStringOrNull(raw.taskName), } } function mapTaskField(value: unknown): RocketlaneTaskField { const raw = toRecordOrNull(value) ?? {} return { - fieldId: asNumber(raw.fieldId), - fieldLabel: asString(raw.fieldLabel), + fieldId: toNumberOrNull(raw.fieldId), + fieldLabel: toStringOrNull(raw.fieldLabel), fieldValue: raw.fieldValue ?? null, - fieldValueLabel: asString(raw.fieldValueLabel), + fieldValueLabel: toStringOrNull(raw.fieldValueLabel), } } @@ -323,54 +308,54 @@ function mapTaskTimeEntryCategory(value: unknown): RocketlaneTaskTimeEntryCatego const raw = toRecordOrNull(value) if (!raw) return null return { - categoryId: asNumber(raw.categoryId), - categoryName: asString(raw.categoryName), + categoryId: toNumberOrNull(raw.categoryId), + categoryName: toStringOrNull(raw.categoryName), } } function mapTaskBudget(value: unknown): RocketlaneTaskBudget { const raw = toRecordOrNull(value) ?? {} return { - budgetId: asNumber(raw.budgetId), - budgetName: asString(raw.budgetName), + budgetId: toNumberOrNull(raw.budgetId), + budgetName: toStringOrNull(raw.budgetName), } } export function mapTask(value: unknown): RocketlaneTask { const raw = toRecordOrNull(value) ?? {} return { - taskId: asNumber(raw.taskId), - taskName: asString(raw.taskName), - taskDescription: asString(raw.taskDescription), - taskPrivateNote: asString(raw.taskPrivateNote), - startDate: asString(raw.startDate), - dueDate: asString(raw.dueDate), - startDateActual: asString(raw.startDateActual), - dueDateActual: asString(raw.dueDateActual), - archived: asBoolean(raw.archived), - effortInMinutes: asNumber(raw.effortInMinutes), - progress: asNumber(raw.progress), - atRisk: asBoolean(raw.atRisk), - type: asString(raw.type), - createdAt: asNumber(raw.createdAt), - updatedAt: asNumber(raw.updatedAt), + taskId: toNumberOrNull(raw.taskId), + taskName: toStringOrNull(raw.taskName), + taskDescription: toStringOrNull(raw.taskDescription), + taskPrivateNote: toStringOrNull(raw.taskPrivateNote), + startDate: toStringOrNull(raw.startDate), + dueDate: toStringOrNull(raw.dueDate), + startDateActual: toStringOrNull(raw.startDateActual), + dueDateActual: toStringOrNull(raw.dueDateActual), + archived: toBooleanOrNull(raw.archived), + effortInMinutes: toNumberOrNull(raw.effortInMinutes), + progress: toNumberOrNull(raw.progress), + atRisk: toBooleanOrNull(raw.atRisk), + type: toStringOrNull(raw.type), + createdAt: toNumberOrNull(raw.createdAt), + updatedAt: toNumberOrNull(raw.updatedAt), createdBy: mapUserSummary(raw.createdBy), updatedBy: mapUserSummary(raw.updatedBy), project: mapTaskProjectRef(raw.project), phase: mapTaskPhaseRef(raw.phase), status: mapTaskChoice(raw.status), priority: mapTaskChoice(raw.priority), - fields: asArray(raw.fields).map(mapTaskField), + fields: toArray(raw.fields).map(mapTaskField), assignees: mapTaskAssignees(raw.assignees), followers: mapTaskFollowers(raw.followers), - dependencies: asArray(raw.dependencies).map(mapTaskLite), + dependencies: toArray(raw.dependencies).map(mapTaskLite), parent: toRecordOrNull(raw.parent) ? mapTaskLite(raw.parent) : null, - externalReferenceId: asString(raw.externalReferenceId), - billable: asBoolean(raw.billable), + externalReferenceId: toStringOrNull(raw.externalReferenceId), + billable: toBooleanOrNull(raw.billable), timeEntryCategory: mapTaskTimeEntryCategory(raw.timeEntryCategory), - financialsBudgets: asArray(raw.financialsBudgets).map(mapTaskBudget), - csatEnabled: asBoolean(raw.csatEnabled), - private: asBoolean(raw.private), + financialsBudgets: toArray(raw.financialsBudgets).map(mapTaskBudget), + csatEnabled: toBooleanOrNull(raw.csatEnabled), + private: toBooleanOrNull(raw.private), } } @@ -765,9 +750,9 @@ export function mapProjectCompany(value: unknown): RocketlaneProjectCompany | nu const raw = toRecordOrNull(value) if (!raw) return null return { - companyId: asNumber(raw.companyId), - companyName: asString(raw.companyName), - companyUrl: asString(raw.companyUrl), + companyId: toNumberOrNull(raw.companyId), + companyName: toStringOrNull(raw.companyName), + companyUrl: toStringOrNull(raw.companyUrl), } } @@ -787,8 +772,8 @@ export function mapProjectStatus(value: unknown): RocketlaneProjectStatus | null const raw = toRecordOrNull(value) if (!raw) return null return { - value: asNumber(raw.value), - label: asString(raw.label), + value: toNumberOrNull(raw.value), + label: toStringOrNull(raw.label), } } @@ -808,10 +793,10 @@ export interface RocketlaneProjectField { export function mapProjectField(value: unknown): RocketlaneProjectField { const raw = toRecordOrNull(value) ?? {} return { - fieldId: asNumber(raw.fieldId), - fieldLabel: asString(raw.fieldLabel), - fieldValue: asString(raw.fieldValue), - fieldValueLabel: asString(raw.fieldValueLabel), + fieldId: toNumberOrNull(raw.fieldId), + fieldLabel: toStringOrNull(raw.fieldLabel), + fieldValue: toStringOrNull(raw.fieldValue), + fieldValueLabel: toStringOrNull(raw.fieldValueLabel), } } @@ -835,8 +820,8 @@ export interface RocketlaneProjectPhase { export function mapProjectPhase(value: unknown): RocketlaneProjectPhase { const raw = toRecordOrNull(value) ?? {} return { - phaseId: asNumber(raw.phaseId), - phaseName: asString(raw.phaseName), + phaseId: toNumberOrNull(raw.phaseId), + phaseName: toStringOrNull(raw.phaseName), } } @@ -856,10 +841,10 @@ export interface RocketlaneProjectSource { export function mapProjectSource(value: unknown): RocketlaneProjectSource { const raw = toRecordOrNull(value) ?? {} return { - prefix: asString(raw.prefix), - startDate: asString(raw.startDate), - templateId: asNumber(raw.templateId), - templateName: asString(raw.templateName), + prefix: toStringOrNull(raw.prefix), + startDate: toStringOrNull(raw.startDate), + templateId: toNumberOrNull(raw.templateId), + templateName: toStringOrNull(raw.templateName), } } @@ -888,10 +873,10 @@ export interface RocketlaneProjectTeamMembers { export function mapProjectTeamMembers(value: unknown): RocketlaneProjectTeamMembers { const raw = toRecordOrNull(value) ?? {} return { - members: asArray(raw.members) + members: toArray(raw.members) .map(mapUserSummary) .filter((m): m is RocketlaneUserSummary => m !== null), - customers: asArray(raw.customers) + customers: toArray(raw.customers) .map(mapUserSummary) .filter((m): m is RocketlaneUserSummary => m !== null), customerChampion: mapUserSummary(raw.customerChampion), @@ -943,17 +928,17 @@ export function mapProjectFinancials(value: unknown): RocketlaneProjectFinancial const rateCard = toRecordOrNull(timeAndMaterialContract.rateCard) ?? {} const subscriptionContract = toRecordOrNull(raw.subscriptionContract) ?? {} return { - contractType: asString(raw.contractType), - revenueRecognitionType: asString(raw.revenueRecognitionType), - fixedFee: asNumber(fixedFeeContract.fixedFee), - projectBudget: asNumber(timeAndMaterialContract.projectBudget), - rateCardId: asNumber(rateCard.rateCardId), - rateCardName: asString(rateCard.rateCardName), - subscriptionFrequency: asString(subscriptionContract.subscriptionFrequency), - subscriptionStartDate: asString(subscriptionContract.subscriptionStartDate), - periodMinutes: asNumber(subscriptionContract.periodMinutes), - periodBudget: asNumber(subscriptionContract.periodBudget), - noOfPeriods: asNumber(subscriptionContract.noOfPeriods), + contractType: toStringOrNull(raw.contractType), + revenueRecognitionType: toStringOrNull(raw.revenueRecognitionType), + fixedFee: toNumberOrNull(fixedFeeContract.fixedFee), + projectBudget: toNumberOrNull(timeAndMaterialContract.projectBudget), + rateCardId: toNumberOrNull(rateCard.rateCardId), + rateCardName: toStringOrNull(rateCard.rateCardName), + subscriptionFrequency: toStringOrNull(subscriptionContract.subscriptionFrequency), + subscriptionStartDate: toStringOrNull(subscriptionContract.subscriptionStartDate), + periodMinutes: toNumberOrNull(subscriptionContract.periodMinutes), + periodBudget: toNumberOrNull(subscriptionContract.periodBudget), + noOfPeriods: toNumberOrNull(subscriptionContract.noOfPeriods), } } @@ -1061,53 +1046,53 @@ export interface RocketlaneProject { export function mapProject(value: unknown): RocketlaneProject { const raw = toRecordOrNull(value) ?? {} return { - projectId: asNumber(raw.projectId), - projectName: asString(raw.projectName), - startDate: asString(raw.startDate), - dueDate: asString(raw.dueDate), - createdAt: asNumber(raw.createdAt), - updatedAt: asNumber(raw.updatedAt), + projectId: toNumberOrNull(raw.projectId), + projectName: toStringOrNull(raw.projectName), + startDate: toStringOrNull(raw.startDate), + dueDate: toStringOrNull(raw.dueDate), + createdAt: toNumberOrNull(raw.createdAt), + updatedAt: toNumberOrNull(raw.updatedAt), owner: mapUserSummary(raw.owner), teamMembers: mapProjectTeamMembers(raw.teamMembers), status: mapProjectStatus(raw.status), - fields: asArray(raw.fields).map(mapProjectField), + fields: toArray(raw.fields).map(mapProjectField), customer: mapProjectCompany(raw.customer), - partnerCompanies: asArray(raw.partnerCompanies) + partnerCompanies: toArray(raw.partnerCompanies) .map(mapProjectCompany) .filter((c): c is RocketlaneProjectCompany => c !== null), - archived: asBoolean(raw.archived), - visibility: asString(raw.visibility), + archived: toBooleanOrNull(raw.archived), + visibility: toStringOrNull(raw.visibility), createdBy: mapUserSummary(raw.createdBy), updatedBy: mapUserSummary(raw.updatedBy), - currency: asString(raw.currency), + currency: toStringOrNull(raw.currency), financials: mapProjectFinancials(raw.financials), - startDateActual: asString(raw.startDateActual), - dueDateActual: asString(raw.dueDateActual), - annualizedRecurringRevenue: asNumber(raw.annualizedRecurringRevenue), - projectFee: asNumber(raw.projectFee), - budgetedHours: asNumber(raw.budgetedHours), - percentageBudgetedHoursConsumed: asNumber(raw.percentageBudgetedHoursConsumed), - percentageBudgetConsumed: asNumber(raw.percentageBudgetConsumed), - trackedHours: asNumber(raw.trackedHours), - trackedMinutes: asNumber(raw.trackedMinutes), - allocatedHours: asNumber(raw.allocatedHours), - allocatedMinutes: asNumber(raw.allocatedMinutes), - billableHours: asNumber(raw.billableHours), - billableMinutes: asNumber(raw.billableMinutes), - nonBillableHours: asNumber(raw.nonBillableHours), - nonBillableMinutes: asNumber(raw.nonBillableMinutes), - remainingHours: asNumber(raw.remainingHours), - remainingMinutes: asNumber(raw.remainingMinutes), - progressPercentage: asNumber(raw.progressPercentage), - currentPhases: asArray(raw.currentPhases).map(mapProjectPhase), - autoAllocation: asBoolean(raw.autoAllocation), - sources: asArray(raw.sources).map(mapProjectSource), - plannedDurationInDays: asNumber(raw.plannedDurationInDays), - inferredProgress: asString(raw.inferredProgress), - projectAgeInDays: asNumber(raw.projectAgeInDays), - customersInvited: asNumber(raw.customersInvited), - customersJoined: asNumber(raw.customersJoined), - externalReferenceId: asString(raw.externalReferenceId), + startDateActual: toStringOrNull(raw.startDateActual), + dueDateActual: toStringOrNull(raw.dueDateActual), + annualizedRecurringRevenue: toNumberOrNull(raw.annualizedRecurringRevenue), + projectFee: toNumberOrNull(raw.projectFee), + budgetedHours: toNumberOrNull(raw.budgetedHours), + percentageBudgetedHoursConsumed: toNumberOrNull(raw.percentageBudgetedHoursConsumed), + percentageBudgetConsumed: toNumberOrNull(raw.percentageBudgetConsumed), + trackedHours: toNumberOrNull(raw.trackedHours), + trackedMinutes: toNumberOrNull(raw.trackedMinutes), + allocatedHours: toNumberOrNull(raw.allocatedHours), + allocatedMinutes: toNumberOrNull(raw.allocatedMinutes), + billableHours: toNumberOrNull(raw.billableHours), + billableMinutes: toNumberOrNull(raw.billableMinutes), + nonBillableHours: toNumberOrNull(raw.nonBillableHours), + nonBillableMinutes: toNumberOrNull(raw.nonBillableMinutes), + remainingHours: toNumberOrNull(raw.remainingHours), + remainingMinutes: toNumberOrNull(raw.remainingMinutes), + progressPercentage: toNumberOrNull(raw.progressPercentage), + currentPhases: toArray(raw.currentPhases).map(mapProjectPhase), + autoAllocation: toBooleanOrNull(raw.autoAllocation), + sources: toArray(raw.sources).map(mapProjectSource), + plannedDurationInDays: toNumberOrNull(raw.plannedDurationInDays), + inferredProgress: toStringOrNull(raw.inferredProgress), + projectAgeInDays: toNumberOrNull(raw.projectAgeInDays), + customersInvited: toNumberOrNull(raw.customersInvited), + customersJoined: toNumberOrNull(raw.customersJoined), + externalReferenceId: toStringOrNull(raw.externalReferenceId), } } @@ -1361,15 +1346,20 @@ export function mapPlaceholder(value: unknown): RocketlanePlaceholder { const project = toRecordOrNull(raw.project) const role = toRecordOrNull(raw.role) return { - placeholderId: asNumber(raw.placeholderId), - placeholderName: asString(raw.placeholderName), + placeholderId: toNumberOrNull(raw.placeholderId), + placeholderName: toStringOrNull(raw.placeholderName), project: project - ? { projectId: asNumber(project.projectId), projectName: asString(project.projectName) } + ? { + projectId: toNumberOrNull(project.projectId), + projectName: toStringOrNull(project.projectName), + } + : null, + role: role + ? { roleId: toNumberOrNull(role.roleId), roleName: toStringOrNull(role.roleName) } : null, - role: role ? { roleId: asNumber(role.roleId), roleName: asString(role.roleName) } : null, - placeholderType: asString(raw.placeholderType), - createdAt: asNumber(raw.createdAt), - updatedAt: asNumber(raw.updatedAt), + placeholderType: toStringOrNull(raw.placeholderType), + createdAt: toNumberOrNull(raw.createdAt), + updatedAt: toNumberOrNull(raw.updatedAt), } } @@ -1452,24 +1442,24 @@ export function mapPlaceholderMapping(value: unknown): RocketlanePlaceholderMapp return { placeholder: placeholder ? { - placeholderId: asNumber(placeholder.placeholderId), - placeholderName: asString(placeholder.placeholderName), + placeholderId: toNumberOrNull(placeholder.placeholderId), + placeholderName: toStringOrNull(placeholder.placeholderName), } : null, - placeholderStatus: asString(raw.placeholderStatus), + placeholderStatus: toStringOrNull(raw.placeholderStatus), user: user ? { - userId: asNumber(user.userId), - firstName: asString(user.firstName), - lastName: asString(user.lastName), - emailId: asString(user.emailId), - role: asString(user.role), + userId: toNumberOrNull(user.userId), + firstName: toStringOrNull(user.firstName), + lastName: toStringOrNull(user.lastName), + emailId: toStringOrNull(user.emailId), + role: toStringOrNull(user.role), } : null, - hourlyCostRate: asNumber(raw.hourlyCostRate), - costRateCurrency: asString(raw.costRateCurrency), - hourlyBillRate: asNumber(raw.hourlyBillRate), - billRateCurrency: asString(raw.billRateCurrency), + hourlyCostRate: toNumberOrNull(raw.hourlyCostRate), + costRateCurrency: toStringOrNull(raw.costRateCurrency), + hourlyBillRate: toNumberOrNull(raw.hourlyBillRate), + billRateCurrency: toStringOrNull(raw.billRateCurrency), } } @@ -1733,9 +1723,9 @@ export interface RocketlaneFieldOption { export function mapFieldOption(value: unknown): RocketlaneFieldOption { const raw = toRecordOrNull(value) ?? {} return { - optionValue: asNumber(raw.optionValue), - optionLabel: asString(raw.optionLabel), - optionColor: asString(raw.optionColor), + optionValue: toNumberOrNull(raw.optionValue), + optionLabel: toStringOrNull(raw.optionLabel), + optionColor: toStringOrNull(raw.optionColor), } } @@ -1774,19 +1764,19 @@ export interface RocketlaneField { export function mapField(value: unknown): RocketlaneField { const raw = toRecordOrNull(value) ?? {} return { - fieldId: asNumber(raw.fieldId), - fieldLabel: asString(raw.fieldLabel), - fieldDescription: asString(raw.fieldDescription), - fieldType: asString(raw.fieldType), - objectType: asString(raw.objectType), - fieldOptions: asArray(raw.fieldOptions).map(mapFieldOption), - ratingScale: asString(raw.ratingScale), + fieldId: toNumberOrNull(raw.fieldId), + fieldLabel: toStringOrNull(raw.fieldLabel), + fieldDescription: toStringOrNull(raw.fieldDescription), + fieldType: toStringOrNull(raw.fieldType), + objectType: toStringOrNull(raw.objectType), + fieldOptions: toArray(raw.fieldOptions).map(mapFieldOption), + ratingScale: toStringOrNull(raw.ratingScale), createdBy: mapUserSummary(raw.createdBy), updatedBy: mapUserSummary(raw.updatedBy), - createdAt: asNumber(raw.createdAt), - updatedAt: asNumber(raw.updatedAt), - enabled: asBoolean(raw.enabled), - private: asBoolean(raw.private), + createdAt: toNumberOrNull(raw.createdAt), + updatedAt: toNumberOrNull(raw.updatedAt), + enabled: toBooleanOrNull(raw.enabled), + private: toBooleanOrNull(raw.private), } } @@ -1971,21 +1961,26 @@ export function mapPhase(value: unknown): RocketlanePhase { const project = toRecordOrNull(raw.project) const status = toRecordOrNull(raw.status) return { - phaseId: asNumber(raw.phaseId), - phaseName: asString(raw.phaseName), + phaseId: toNumberOrNull(raw.phaseId), + phaseName: toStringOrNull(raw.phaseName), project: project - ? { projectId: asNumber(project.projectId), projectName: asString(project.projectName) } + ? { + projectId: toNumberOrNull(project.projectId), + projectName: toStringOrNull(project.projectName), + } : null, - startDate: asString(raw.startDate), - dueDate: asString(raw.dueDate), - startDateActual: asString(raw.startDateActual), - dueDateActual: asString(raw.dueDateActual), - createdAt: asNumber(raw.createdAt), - updatedAt: asNumber(raw.updatedAt), + startDate: toStringOrNull(raw.startDate), + dueDate: toStringOrNull(raw.dueDate), + startDateActual: toStringOrNull(raw.startDateActual), + dueDateActual: toStringOrNull(raw.dueDateActual), + createdAt: toNumberOrNull(raw.createdAt), + updatedAt: toNumberOrNull(raw.updatedAt), createdBy: mapUserSummary(raw.createdBy), updatedBy: mapUserSummary(raw.updatedBy), - status: status ? { value: asNumber(status.value), label: asString(status.label) } : null, - private: asBoolean(raw.private), + status: status + ? { value: toNumberOrNull(status.value), label: toStringOrNull(status.label) } + : null, + private: toBooleanOrNull(raw.private), } } @@ -2200,8 +2195,8 @@ function mapTimeEntryProject(value: unknown): RocketlaneTimeEntryProject | null const raw = toRecordOrNull(value) if (!raw) return null return { - projectId: asNumber(raw.projectId), - projectName: asString(raw.projectName), + projectId: toNumberOrNull(raw.projectId), + projectName: toStringOrNull(raw.projectName), } } @@ -2209,8 +2204,8 @@ function mapTimeEntryTask(value: unknown): RocketlaneTimeEntryTask | null { const raw = toRecordOrNull(value) if (!raw) return null return { - taskId: asNumber(raw.taskId), - taskName: asString(raw.taskName), + taskId: toNumberOrNull(raw.taskId), + taskName: toStringOrNull(raw.taskName), } } @@ -2218,8 +2213,8 @@ function mapTimeEntryPhase(value: unknown): RocketlaneTimeEntryPhase | null { const raw = toRecordOrNull(value) if (!raw) return null return { - phaseId: asNumber(raw.phaseId), - phaseName: asString(raw.phaseName), + phaseId: toNumberOrNull(raw.phaseId), + phaseName: toStringOrNull(raw.phaseName), } } @@ -2227,8 +2222,8 @@ export function mapTimeEntryCategory(value: unknown): RocketlaneTimeEntryCategor const raw = toRecordOrNull(value) if (!raw) return null return { - categoryId: asNumber(raw.categoryId), - categoryName: asString(raw.categoryName), + categoryId: toNumberOrNull(raw.categoryId), + categoryName: toStringOrNull(raw.categoryName), } } @@ -2236,51 +2231,51 @@ function mapTimeEntryRate(value: unknown): RocketlaneTimeEntryRate | null { const raw = toRecordOrNull(value) if (!raw) return null return { - rate: asNumber(raw.rate), - currency: asString(raw.currency), + rate: toNumberOrNull(raw.rate), + currency: toStringOrNull(raw.currency), } } function mapTimeEntryField(value: unknown): RocketlaneTimeEntryField { const raw = toRecordOrNull(value) ?? {} return { - fieldId: asNumber(raw.fieldId), - fieldLabel: asString(raw.fieldLabel), + fieldId: toNumberOrNull(raw.fieldId), + fieldLabel: toStringOrNull(raw.fieldLabel), fieldValue: raw.fieldValue ?? null, - fieldValueLabel: asString(raw.fieldValueLabel), + fieldValueLabel: toStringOrNull(raw.fieldValueLabel), } } export function mapTimeEntry(value: unknown): RocketlaneTimeEntry { const raw = toRecordOrNull(value) ?? {} return { - timeEntryId: asNumber(raw.timeEntryId), - date: asString(raw.date), - minutes: asNumber(raw.minutes), - activityName: asString(raw.activityName), + timeEntryId: toNumberOrNull(raw.timeEntryId), + date: toStringOrNull(raw.date), + minutes: toNumberOrNull(raw.minutes), + activityName: toStringOrNull(raw.activityName), project: mapTimeEntryProject(raw.project), task: mapTimeEntryTask(raw.task), projectPhase: mapTimeEntryPhase(raw.projectPhase), - billable: asBoolean(raw.billable), + billable: toBooleanOrNull(raw.billable), user: mapUserSummary(raw.user), - notes: asString(raw.notes), + notes: toStringOrNull(raw.notes), category: mapTimeEntryCategory(raw.category), - sourceType: asString(raw.sourceType), - status: asString(raw.status), - createdAt: asNumber(raw.createdAt), - updatedAt: asNumber(raw.updatedAt), + sourceType: toStringOrNull(raw.sourceType), + status: toStringOrNull(raw.status), + createdAt: toNumberOrNull(raw.createdAt), + updatedAt: toNumberOrNull(raw.updatedAt), createdBy: mapUserSummary(raw.createdBy), updatedBy: mapUserSummary(raw.updatedBy), submittedBy: mapUserSummary(raw.submittedBy), - submittedAt: asNumber(raw.submittedAt), + submittedAt: toNumberOrNull(raw.submittedAt), approvedBy: mapUserSummary(raw.approvedBy), - approvedAt: asNumber(raw.approvedAt), + approvedAt: toNumberOrNull(raw.approvedAt), rejectedBy: mapUserSummary(raw.rejectedBy), - rejectedAt: asNumber(raw.rejectedAt), - deleted: asBoolean(raw.deleted), + rejectedAt: toNumberOrNull(raw.rejectedAt), + deleted: toBooleanOrNull(raw.deleted), costRate: mapTimeEntryRate(raw.costRate), billRate: mapTimeEntryRate(raw.billRate), - fields: asArray(raw.fields).map(mapTimeEntryField), + fields: toArray(raw.fields).map(mapTimeEntryField), } } @@ -2623,19 +2618,19 @@ export function mapSpace(value: unknown): RocketlaneSpace { const raw = toRecordOrNull(value) ?? {} const project = toRecordOrNull(raw.project) return { - spaceId: asNumber(raw.spaceId), - spaceName: asString(raw.spaceName), + spaceId: toNumberOrNull(raw.spaceId), + spaceName: toStringOrNull(raw.spaceName), project: project ? { - projectId: asNumber(project.projectId), - projectName: asString(project.projectName), + projectId: toNumberOrNull(project.projectId), + projectName: toStringOrNull(project.projectName), } : null, - createdAt: asNumber(raw.createdAt), + createdAt: toNumberOrNull(raw.createdAt), createdBy: mapUserSummary(raw.createdBy), - updatedAt: asNumber(raw.updatedAt), + updatedAt: toNumberOrNull(raw.updatedAt), updatedBy: mapUserSummary(raw.updatedBy), - private: asBoolean(raw.private), + private: toBooleanOrNull(raw.private), } } @@ -2781,27 +2776,27 @@ export function mapSpaceDocument(value: unknown): RocketlaneSpaceDocument { const space = toRecordOrNull(raw.space) const source = toRecordOrNull(raw.source) return { - spaceDocumentId: asNumber(raw.spaceDocumentId), - spaceDocumentName: asString(raw.spaceDocumentName), + spaceDocumentId: toNumberOrNull(raw.spaceDocumentId), + spaceDocumentName: toStringOrNull(raw.spaceDocumentName), space: space ? { - spaceId: asNumber(space.spaceId), - spaceName: asString(space.spaceName), + spaceId: toNumberOrNull(space.spaceId), + spaceName: toStringOrNull(space.spaceName), } : null, - spaceDocumentType: asString(raw.spaceDocumentType), - url: asString(raw.url), + spaceDocumentType: toStringOrNull(raw.spaceDocumentType), + url: toStringOrNull(raw.url), source: source ? { - templateId: asNumber(source.templateId), - templateName: asString(source.templateName), + templateId: toNumberOrNull(source.templateId), + templateName: toStringOrNull(source.templateName), } : null, - createdAt: asNumber(raw.createdAt), + createdAt: toNumberOrNull(raw.createdAt), createdBy: mapUserSummary(raw.createdBy), - updatedAt: asNumber(raw.updatedAt), + updatedAt: toNumberOrNull(raw.updatedAt), updatedBy: mapUserSummary(raw.updatedBy), - private: asBoolean(raw.private), + private: toBooleanOrNull(raw.private), } } @@ -3006,50 +3001,50 @@ export function mapUser(value: unknown): RocketlaneUser { const permission = toRecordOrNull(raw.permission) const holidayCalendar = toRecordOrNull(raw.holidayCalendar) return { - userId: asNumber(raw.userId), - email: asString(raw.email), - firstName: asString(raw.firstName), - lastName: asString(raw.lastName), - type: asString(raw.type), - status: asString(raw.status), + userId: toNumberOrNull(raw.userId), + email: toStringOrNull(raw.email), + firstName: toStringOrNull(raw.firstName), + lastName: toStringOrNull(raw.lastName), + type: toStringOrNull(raw.type), + status: toStringOrNull(raw.status), role: role ? { - roleId: asNumber(role.roleId), - roleName: asString(role.roleName), + roleId: toNumberOrNull(role.roleId), + roleName: toStringOrNull(role.roleName), } : null, company: company ? { - companyId: asNumber(company.companyId), - companyName: asString(company.companyName), + companyId: toNumberOrNull(company.companyId), + companyName: toStringOrNull(company.companyName), } : null, permission: permission ? { - permissionId: asNumber(permission.permissionId), - permissionName: asString(permission.permissionName), + permissionId: toNumberOrNull(permission.permissionId), + permissionName: toStringOrNull(permission.permissionName), } : null, - fields: asArray(raw.fields).map((field) => { + fields: toArray(raw.fields).map((field) => { const fieldRaw = toRecordOrNull(field) ?? {} return { - fieldId: asNumber(fieldRaw.fieldId), - fieldLabel: asString(fieldRaw.fieldLabel), - fieldValue: asString(fieldRaw.fieldValue), - fieldValueLabel: asString(fieldRaw.fieldValueLabel), + fieldId: toNumberOrNull(fieldRaw.fieldId), + fieldLabel: toStringOrNull(fieldRaw.fieldLabel), + fieldValue: toStringOrNull(fieldRaw.fieldValue), + fieldValueLabel: toStringOrNull(fieldRaw.fieldValueLabel), } }), - capacityInMinutes: asNumber(raw.capacityInMinutes), + capacityInMinutes: toNumberOrNull(raw.capacityInMinutes), holidayCalendar: holidayCalendar ? { - calenderId: asNumber(holidayCalendar.calenderId), - calenderName: asString(holidayCalendar.calenderName), + calenderId: toNumberOrNull(holidayCalendar.calenderId), + calenderName: toStringOrNull(holidayCalendar.calenderName), } : null, - profilePictureUrl: asString(raw.profilePictureUrl), - createdAt: asNumber(raw.createdAt), + profilePictureUrl: toStringOrNull(raw.profilePictureUrl), + createdAt: toNumberOrNull(raw.createdAt), createdBy: mapUserSummary(raw.createdBy), - updatedAt: asNumber(raw.updatedAt), + updatedAt: toNumberOrNull(raw.updatedAt), updatedBy: mapUserSummary(raw.updatedBy), } } @@ -3330,8 +3325,8 @@ function mapTimeOffNotifyUsers(value: unknown): RocketlaneTimeOffNotifyUsers | n const raw = toRecordOrNull(value) if (!raw) return null return { - projectOwners: asBoolean(raw.projectOwners), - others: asArray(raw.others) + projectOwners: toBooleanOrNull(raw.projectOwners), + others: toArray(raw.others) .map(mapUserSummary) .filter((user): user is RocketlaneUserSummary => user !== null), } @@ -3343,15 +3338,15 @@ function mapTimeOffNotifyUsers(value: unknown): RocketlaneTimeOffNotifyUsers | n export function mapTimeOff(value: unknown): RocketlaneTimeOff { const raw = toRecordOrNull(value) ?? {} return { - timeOffId: asNumber(raw.timeOffId), + timeOffId: toNumberOrNull(raw.timeOffId), user: mapUserSummary(raw.user), - note: asString(raw.note), - startDate: asString(raw.startDate), - endDate: asString(raw.endDate), - durationInMinutes: asNumber(raw.durationInMinutes), - type: asString(raw.type), + note: toStringOrNull(raw.note), + startDate: toStringOrNull(raw.startDate), + endDate: toStringOrNull(raw.endDate), + durationInMinutes: toNumberOrNull(raw.durationInMinutes), + type: toStringOrNull(raw.type), notifyUsers: mapTimeOffNotifyUsers(raw.notifyUsers), - createdAt: asNumber(raw.createdAt), + createdAt: toNumberOrNull(raw.createdAt), createdBy: mapUserSummary(raw.createdBy), } } @@ -3522,8 +3517,8 @@ function mapResourceAllocationRole(value: unknown): RocketlaneResourceAllocation const raw = toRecordOrNull(value) if (!raw) return null return { - roleId: asNumber(raw.roleId), - roleName: asString(raw.roleName), + roleId: toNumberOrNull(raw.roleId), + roleName: toStringOrNull(raw.roleName), } } @@ -3544,8 +3539,8 @@ function mapResourceAllocationPlaceholder( const raw = toRecordOrNull(value) if (!raw) return null return { - placeholderId: asNumber(raw.placeholderId), - placeholderName: asString(raw.placeholderName), + placeholderId: toNumberOrNull(raw.placeholderId), + placeholderName: toStringOrNull(raw.placeholderName), role: mapResourceAllocationRole(raw.role), } } @@ -3556,10 +3551,10 @@ function mapResourceAllocationDuration( const raw = toRecordOrNull(value) if (!raw) return null return { - daysConsider: asNumber(raw.daysConsider), - seconds: asNumber(raw.seconds), - minutes: asNumber(raw.minutes), - hours: asNumber(raw.hours), + daysConsider: toNumberOrNull(raw.daysConsider), + seconds: toNumberOrNull(raw.seconds), + minutes: toNumberOrNull(raw.minutes), + hours: toNumberOrNull(raw.hours), } } @@ -3567,16 +3562,16 @@ function mapResourceAllocationProject(value: unknown): RocketlaneResourceAllocat const raw = toRecordOrNull(value) if (!raw) return null return { - projectId: asNumber(raw.projectId), - projectName: asString(raw.projectName), + projectId: toNumberOrNull(raw.projectId), + projectName: toStringOrNull(raw.projectName), } } function mapResourceAllocationTask(value: unknown): RocketlaneResourceAllocationTask { const raw = toRecordOrNull(value) ?? {} return { - taskId: asNumber(raw.taskId), - taskName: asString(raw.taskName), + taskId: toNumberOrNull(raw.taskId), + taskName: toStringOrNull(raw.taskName), } } @@ -3587,20 +3582,20 @@ function mapResourceAllocationTask(value: unknown): RocketlaneResourceAllocation export function mapResourceAllocation(value: unknown): RocketlaneResourceAllocation { const raw = toRecordOrNull(value) ?? {} return { - startDate: asString(raw.startDate), - endDate: asString(raw.endDate), - secondsPerDay: asNumber(raw.secondsPerDay), - minutesPerDay: asNumber(raw.minutesPerDay), - hoursPerDay: asNumber(raw.hoursPerDay), + startDate: toStringOrNull(raw.startDate), + endDate: toStringOrNull(raw.endDate), + secondsPerDay: toNumberOrNull(raw.secondsPerDay), + minutesPerDay: toNumberOrNull(raw.minutesPerDay), + hoursPerDay: toNumberOrNull(raw.hoursPerDay), duration: mapResourceAllocationDuration(raw.duration), - allocationType: asString(raw.allocationType), - allocationFor: asString(raw.allocationFor), + allocationType: toStringOrNull(raw.allocationType), + allocationFor: toStringOrNull(raw.allocationFor), project: mapResourceAllocationProject(raw.project), - tasks: asArray(raw.tasks).map(mapResourceAllocationTask), + tasks: toArray(raw.tasks).map(mapResourceAllocationTask), member: mapResourceAllocationMember(raw.member), placeholder: mapResourceAllocationPlaceholder(raw.placeholder), - createdAt: asNumber(raw.createdAt), - updatedAt: asNumber(raw.updatedAt), + createdAt: toNumberOrNull(raw.createdAt), + updatedAt: toNumberOrNull(raw.updatedAt), createdBy: mapUserSummary(raw.createdBy), updatedBy: mapUserSummary(raw.updatedBy), } @@ -3919,39 +3914,39 @@ function mapInvoiceCompany(value: unknown): RocketlaneInvoiceCompany | null { const raw = toRecordOrNull(value) if (!raw) return null return { - companyId: asNumber(raw.companyId), - companyName: asString(raw.companyName), - companyUrl: asString(raw.companyUrl), + companyId: toNumberOrNull(raw.companyId), + companyName: toStringOrNull(raw.companyName), + companyUrl: toStringOrNull(raw.companyUrl), } } function mapInvoiceProject(value: unknown): RocketlaneInvoiceProject { const raw = toRecordOrNull(value) ?? {} return { - projectId: asNumber(raw.projectId), - projectName: asString(raw.projectName), + projectId: toNumberOrNull(raw.projectId), + projectName: toStringOrNull(raw.projectName), } } function mapInvoiceField(value: unknown): RocketlaneInvoiceField { const raw = toRecordOrNull(value) ?? {} return { - fieldId: asNumber(raw.fieldId), - fieldLabel: asString(raw.fieldLabel), + fieldId: toNumberOrNull(raw.fieldId), + fieldLabel: toStringOrNull(raw.fieldLabel), fieldValue: raw.fieldValue ?? null, - fieldValueLabel: asString(raw.fieldValueLabel), + fieldValueLabel: toStringOrNull(raw.fieldValueLabel), } } function mapInvoiceAttachment(value: unknown): RocketlaneInvoiceAttachment { const raw = toRecordOrNull(value) ?? {} return { - attachmentId: asNumber(raw.attachmentId), - attachmentName: asString(raw.attachmentName), - createdAt: asNumber(raw.createdAt), - location: asString(raw.location), - thumbLocation: asString(raw.thumbLocation), - visibility: asBoolean(raw.visibility), + attachmentId: toNumberOrNull(raw.attachmentId), + attachmentName: toStringOrNull(raw.attachmentName), + createdAt: toNumberOrNull(raw.createdAt), + location: toStringOrNull(raw.location), + thumbLocation: toStringOrNull(raw.thumbLocation), + visibility: toBooleanOrNull(raw.visibility), } } @@ -3961,27 +3956,27 @@ function mapInvoiceAttachment(value: unknown): RocketlaneInvoiceAttachment { export function mapInvoice(value: unknown): RocketlaneInvoice { const raw = toRecordOrNull(value) ?? {} return { - invoiceId: asNumber(raw.invoiceId), - invoiceNumber: asString(raw.invoiceNumber), - dateOfIssue: asString(raw.dateOfIssue), - dueDate: asString(raw.dueDate), - currency: asString(raw.currency), - status: asString(raw.status), - amount: asNumber(raw.amount), - tax: asNumber(raw.tax), - subTotal: asNumber(raw.subTotal), - amountOutstanding: asNumber(raw.amountOutstanding), - amountPaid: asNumber(raw.amountPaid), - amountWrittenOff: asNumber(raw.amountWrittenOff), - notes: asString(raw.notes), - createdAt: asNumber(raw.createdAt), - updatedAt: asNumber(raw.updatedAt), + invoiceId: toNumberOrNull(raw.invoiceId), + invoiceNumber: toStringOrNull(raw.invoiceNumber), + dateOfIssue: toStringOrNull(raw.dateOfIssue), + dueDate: toStringOrNull(raw.dueDate), + currency: toStringOrNull(raw.currency), + status: toStringOrNull(raw.status), + amount: toNumberOrNull(raw.amount), + tax: toNumberOrNull(raw.tax), + subTotal: toNumberOrNull(raw.subTotal), + amountOutstanding: toNumberOrNull(raw.amountOutstanding), + amountPaid: toNumberOrNull(raw.amountPaid), + amountWrittenOff: toNumberOrNull(raw.amountWrittenOff), + notes: toStringOrNull(raw.notes), + createdAt: toNumberOrNull(raw.createdAt), + updatedAt: toNumberOrNull(raw.updatedAt), createdBy: mapUserSummary(raw.createdBy), updatedBy: mapUserSummary(raw.updatedBy), company: mapInvoiceCompany(raw.company), - projects: asArray(raw.projects).map(mapInvoiceProject), - fields: asArray(raw.fields).map(mapInvoiceField), - attachments: asArray(raw.attachments).map(mapInvoiceAttachment), + projects: toArray(raw.projects).map(mapInvoiceProject), + fields: toArray(raw.fields).map(mapInvoiceField), + attachments: toArray(raw.attachments).map(mapInvoiceAttachment), } } @@ -3991,12 +3986,12 @@ export function mapInvoice(value: unknown): RocketlaneInvoice { export function mapInvoicePayment(value: unknown): RocketlaneInvoicePayment { const raw = toRecordOrNull(value) ?? {} return { - paymentId: asNumber(raw.paymentId), - paymentRecordType: asString(raw.paymentRecordType), - currency: asString(raw.currency), - paymentDate: asString(raw.paymentDate), - amount: asNumber(raw.amount), - notes: asString(raw.notes), + paymentId: toNumberOrNull(raw.paymentId), + paymentRecordType: toStringOrNull(raw.paymentRecordType), + currency: toStringOrNull(raw.currency), + paymentDate: toStringOrNull(raw.paymentDate), + amount: toNumberOrNull(raw.amount), + notes: toStringOrNull(raw.notes), } } @@ -4004,21 +3999,21 @@ function mapInvoiceLineItemTaxCode(value: unknown): RocketlaneInvoiceLineItemTax const raw = toRecordOrNull(value) if (!raw) return null return { - taxCodeId: asNumber(raw.taxCodeId), - taxCodeName: asString(raw.taxCodeName), - taxCodeRate: asNumber(raw.taxCodeRate), - taxCodeAmount: asNumber(raw.taxCodeAmount), + taxCodeId: toNumberOrNull(raw.taxCodeId), + taxCodeName: toStringOrNull(raw.taxCodeName), + taxCodeRate: toNumberOrNull(raw.taxCodeRate), + taxCodeAmount: toNumberOrNull(raw.taxCodeAmount), } } function mapInvoiceLineItemTaxComponent(value: unknown): RocketlaneInvoiceLineItemTaxComponent { const raw = toRecordOrNull(value) ?? {} return { - taxComponentId: asNumber(raw.taxComponentId), - taxComponentName: asString(raw.taxComponentName), - taxComponentRate: asNumber(raw.taxComponentRate), - taxComponentAmount: asNumber(raw.taxComponentAmount), - taxComponentType: asString(raw.taxComponentType), + taxComponentId: toNumberOrNull(raw.taxComponentId), + taxComponentName: toStringOrNull(raw.taxComponentName), + taxComponentRate: toNumberOrNull(raw.taxComponentRate), + taxComponentAmount: toNumberOrNull(raw.taxComponentAmount), + taxComponentType: toStringOrNull(raw.taxComponentType), } } @@ -4028,15 +4023,15 @@ function mapInvoiceLineItemTaxComponent(value: unknown): RocketlaneInvoiceLineIt export function mapInvoiceLineItem(value: unknown): RocketlaneInvoiceLineItem { const raw = toRecordOrNull(value) ?? {} return { - invoiceLineItemId: asNumber(raw.invoiceLineItemId), - description: asString(raw.description), - quantity: asNumber(raw.quantity), - unitPrice: asNumber(raw.unitPrice), - amount: asNumber(raw.amount), - sourceId: asNumber(raw.sourceId), - sourceType: asString(raw.sourceType), + invoiceLineItemId: toNumberOrNull(raw.invoiceLineItemId), + description: toStringOrNull(raw.description), + quantity: toNumberOrNull(raw.quantity), + unitPrice: toNumberOrNull(raw.unitPrice), + amount: toNumberOrNull(raw.amount), + sourceId: toNumberOrNull(raw.sourceId), + sourceType: toStringOrNull(raw.sourceType), taxCode: mapInvoiceLineItemTaxCode(raw.taxCode), - taxComponents: asArray(raw.taxComponents).map(mapInvoiceLineItemTaxComponent), + taxComponents: toArray(raw.taxComponents).map(mapInvoiceLineItemTaxComponent), } } diff --git a/apps/sim/tools/smartlead/utils.ts b/apps/sim/tools/smartlead/utils.ts index 097cfeaaa12..b666fb986a4 100644 --- a/apps/sim/tools/smartlead/utils.ts +++ b/apps/sim/tools/smartlead/utils.ts @@ -1,4 +1,4 @@ -import { filterUndefined, isRecordLike, toRecord } from '@sim/utils/object' +import { filterUndefined, isRecordLike, toArray, toRecord } from '@sim/utils/object' import type { SmartleadBaseParams, SmartleadCampaign, @@ -550,10 +550,6 @@ export function mapCreatedCampaign(record: Record): { } } -function toArray(value: unknown): unknown[] { - return Array.isArray(value) ? value : [] -} - function toStringArray(value: unknown): string[] { return toArray(value) .map((item) => (typeof item === 'string' ? item : null)) diff --git a/apps/sim/tools/splunk/get_fired_alerts.ts b/apps/sim/tools/splunk/get_fired_alerts.ts index 82eb11a6494..2b182eea093 100644 --- a/apps/sim/tools/splunk/get_fired_alerts.ts +++ b/apps/sim/tools/splunk/get_fired_alerts.ts @@ -1,8 +1,8 @@ +import { toStringOrNull } from '@sim/utils/coerce' import { ErrorExtractorId } from '@/tools/error-extractors' import type { SplunkGetFiredAlertsParams, SplunkGetFiredAlertsResponse } from '@/tools/splunk/types' import { asNumber, - asString, buildSplunkHeaders, buildSplunkUrl, getEntryContent, @@ -58,17 +58,17 @@ export const getFiredAlertsTool: ToolConfig< const content = getEntryContent(entry) return { name: getEntryName(entry), - id: asString(entry.id), - updated: asString(entry.updated), - savedSearchName: asString(content.savedsearch_name), - alertType: asString(content.alert_type), + id: toStringOrNull(entry.id), + updated: toStringOrNull(entry.updated), + savedSearchName: toStringOrNull(content.savedsearch_name), + alertType: toStringOrNull(content.alert_type), severity: asNumber(content.severity), - sid: asString(content.sid), + sid: toStringOrNull(content.sid), triggerTime: asNumber(content.trigger_time), - triggerTimeRendered: asString(content.trigger_time_rendered), - expirationTimeRendered: asString(content.expiration_time_rendered), + triggerTimeRendered: toStringOrNull(content.trigger_time_rendered), + expirationTimeRendered: toStringOrNull(content.expiration_time_rendered), triggeredAlerts: asNumber(content.triggered_alerts), - actions: asString(content.actions), + actions: toStringOrNull(content.actions), } }), }, diff --git a/apps/sim/tools/splunk/get_search_job.ts b/apps/sim/tools/splunk/get_search_job.ts index 55bc4318746..be976bfb023 100644 --- a/apps/sim/tools/splunk/get_search_job.ts +++ b/apps/sim/tools/splunk/get_search_job.ts @@ -1,9 +1,9 @@ +import { toStringOrNull } from '@sim/utils/coerce' import { ErrorExtractorId } from '@/tools/error-extractors' import type { SplunkGetSearchJobParams, SplunkGetSearchJobResponse } from '@/tools/splunk/types' import { asBoolean, asNumber, - asString, buildSplunkHeaders, buildSplunkUrl, getEntryContent, @@ -45,9 +45,9 @@ export const getSearchJobTool: ToolConfig) ?? null, diff --git a/apps/sim/tools/splunk/list_apps.ts b/apps/sim/tools/splunk/list_apps.ts index 64f4aa81252..c7a302fe46c 100644 --- a/apps/sim/tools/splunk/list_apps.ts +++ b/apps/sim/tools/splunk/list_apps.ts @@ -1,8 +1,8 @@ +import { toStringOrNull } from '@sim/utils/coerce' import { ErrorExtractorId } from '@/tools/error-extractors' import type { SplunkListAppsParams, SplunkListAppsResponse } from '@/tools/splunk/types' import { asBoolean, - asString, buildSplunkHeaders, buildSplunkUrl, getEntryContent, @@ -58,13 +58,13 @@ export const listAppsTool: ToolConfig { /** * The reference renders the job entry's `earliestTime` as an ISO string but * `searchEarliestTime` as a bare number (`1308589800.000000000`). Reading it with - * `asString` returned `null` for every JSON response, which is what + * `toStringOrNull` returned `null` for every JSON response, which is what * `output_mode=json` always produces. */ describe('getSearchJobTool time bounds', () => { diff --git a/apps/sim/tools/splunk/utils.ts b/apps/sim/tools/splunk/utils.ts index 8e18cfbaab5..e26810441d4 100644 --- a/apps/sim/tools/splunk/utils.ts +++ b/apps/sim/tools/splunk/utils.ts @@ -1,3 +1,4 @@ +import { toStringOrNull } from '@sim/utils/coerce' import type { SplunkBaseParams, SplunkMessage, SplunkSavedSearch } from '@/tools/splunk/types' import type { ToolConfig } from '@/tools/types' @@ -301,7 +302,8 @@ export async function readSplunkDispatchJson(response: Response): Promise { const record = (message ?? {}) as Record - return { type: asString(record.type), text: asString(record.text) } + return { type: toStringOrNull(record.type), text: toStringOrNull(record.text) } }) } @@ -451,20 +449,20 @@ export function mapSavedSearchEntry(entry: unknown): SplunkSavedSearch { const content = getEntryContent(atomEntry) return { name: getEntryName(atomEntry), - id: asString(atomEntry?.id), + id: toStringOrNull(atomEntry?.id), author: getEntryAuthor(atomEntry), - updated: asString(atomEntry?.updated), - search: asString(content.search), - qualifiedSearch: asString(content.qualifiedSearch), - description: asString(content.description), + updated: toStringOrNull(atomEntry?.updated), + search: toStringOrNull(content.search), + qualifiedSearch: toStringOrNull(content.qualifiedSearch), + description: toStringOrNull(content.description), disabled: asBoolean(content.disabled), isScheduled: asBoolean(content.is_scheduled), isVisible: asBoolean(content.is_visible), - cronSchedule: asString(content.cron_schedule), - nextScheduledTime: asString(content.next_scheduled_time), - alertType: asString(content.alert_type), - dispatchEarliestTime: asString(content['dispatch.earliest_time']), - dispatchLatestTime: asString(content['dispatch.latest_time']), + cronSchedule: toStringOrNull(content.cron_schedule), + nextScheduledTime: toStringOrNull(content.next_scheduled_time), + alertType: toStringOrNull(content.alert_type), + dispatchEarliestTime: toStringOrNull(content['dispatch.earliest_time']), + dispatchLatestTime: toStringOrNull(content['dispatch.latest_time']), } } diff --git a/apps/sim/tools/trello/shared.ts b/apps/sim/tools/trello/shared.ts index 81911ad84a9..c61a1cf94f3 100644 --- a/apps/sim/tools/trello/shared.ts +++ b/apps/sim/tools/trello/shared.ts @@ -1,3 +1,4 @@ +import { toBooleanOrNull, toStringOrNull } from '@sim/utils/coerce' import { isRecordLike, toRecordOrNull } from '@sim/utils/object' import type { TrelloAction, @@ -24,14 +25,6 @@ function getRequiredString(value: unknown, field: string): string { throw new Error(`Trello response is missing required field: ${field}`) } -function getOptionalString(value: unknown): string | null { - return typeof value === 'string' ? value : null -} - -function getOptionalBoolean(value: unknown): boolean | null { - return typeof value === 'boolean' ? value : null -} - function getNumber(value: unknown): number { if (typeof value === 'number' && Number.isFinite(value)) { return value @@ -81,7 +74,7 @@ function mapTrelloLabel(value: unknown): TrelloLabel | null { return { id: value.id, name: typeof value.name === 'string' ? value.name : '', - color: getOptionalString(value.color), + color: toStringOrNull(value.color), } } @@ -92,8 +85,8 @@ export function mapTrelloMember(value: unknown): TrelloMember | null { return { id: value.id, - fullName: getOptionalString(value.fullName), - username: getOptionalString(value.username), + fullName: toStringOrNull(value.fullName), + username: toStringOrNull(value.username), } } @@ -105,9 +98,9 @@ function mapActionCardTarget(value: unknown): TrelloActionCardTarget | null { return { id: value.id, name: value.name, - shortLink: getOptionalString(value.shortLink), + shortLink: toStringOrNull(value.shortLink), idShort: getOptionalNumber(value.idShort), - due: getOptionalString(value.due), + due: toStringOrNull(value.due), } } @@ -119,7 +112,7 @@ function mapActionBoardTarget(value: unknown): TrelloActionBoardTarget | null { return { id: value.id, name: value.name, - shortLink: getOptionalString(value.shortLink), + shortLink: toStringOrNull(value.shortLink), } } @@ -173,8 +166,8 @@ export function mapTrelloCard(value: unknown): TrelloCard { closed: typeof value.closed === 'boolean' ? value.closed : false, labelIds, labels, - due: getOptionalString(value.due), - dueComplete: getOptionalBoolean(value.dueComplete), + due: toStringOrNull(value.due), + dueComplete: toBooleanOrNull(value.dueComplete), } } @@ -189,7 +182,7 @@ export function mapTrelloBoard(value: unknown): TrelloBoard { desc: typeof value.desc === 'string' ? value.desc : '', url: getRequiredString(value.url, 'url'), closed: typeof value.closed === 'boolean' ? value.closed : false, - idOrganization: getOptionalString(value.idOrganization), + idOrganization: toStringOrNull(value.idOrganization), } } @@ -202,7 +195,7 @@ export function mapTrelloChecklist(value: unknown): TrelloChecklist { id: getRequiredString(value.id, 'id'), name: getRequiredString(value.name, 'name'), idCard: getRequiredString(value.idCard, 'idCard'), - idBoard: getOptionalString(value.idBoard), + idBoard: toStringOrNull(value.idBoard), pos: getNumber(value.pos), } } @@ -217,7 +210,7 @@ export function mapTrelloChecklistItem(value: unknown): TrelloChecklistItem { name: getRequiredString(value.name, 'name'), state: getRequiredString(value.state, 'state'), pos: getNumber(value.pos), - idChecklist: getOptionalString(value.idChecklist), + idChecklist: toStringOrNull(value.idChecklist), } } @@ -233,7 +226,7 @@ export function mapTrelloAction(value: unknown): TrelloAction { type: getRequiredString(value.type, 'type'), date: getRequiredString(value.date, 'date'), idMemberCreator: getRequiredString(value.idMemberCreator, 'idMemberCreator'), - text: data ? getOptionalString(data.text) : null, + text: data ? toStringOrNull(data.text) : null, memberCreator: mapTrelloMember(value.memberCreator), card: data ? mapActionCardTarget(data.card) : null, board: data ? mapActionBoardTarget(data.board) : null, diff --git a/apps/sim/tools/uptimerobot/types.ts b/apps/sim/tools/uptimerobot/types.ts index 9c906cc265f..57e15e0f82c 100644 --- a/apps/sim/tools/uptimerobot/types.ts +++ b/apps/sim/tools/uptimerobot/types.ts @@ -1,5 +1,6 @@ +import { toBooleanOrNull, toNumberOrNull, toStringOrNull } from '@sim/utils/coerce' import { getErrorMessage } from '@sim/utils/errors' -import { toRecordOrNull } from '@sim/utils/object' +import { toArray, toRecordOrNull } from '@sim/utils/object' import type { OutputProperty, ToolResponse } from '@/tools/types' /** Base URL for the UptimeRobot v3 REST API. */ @@ -180,26 +181,6 @@ export interface UptimeRobotAccount { type Raw = Record -function asString(value: unknown): string | null { - return typeof value === 'string' ? value : null -} - -function asNumber(value: unknown): number | null { - return typeof value === 'number' ? value : null -} - -function asBoolean(value: unknown): boolean | null { - return typeof value === 'boolean' ? value : null -} - -function asEnum(value: unknown): string | null { - return typeof value === 'string' ? value : null -} - -function asArray(value: unknown): unknown[] { - return Array.isArray(value) ? value : [] -} - // endregion // region Request body builders @@ -309,145 +290,145 @@ export function buildMaintenanceWindowBody( export function mapMonitor(raw: Raw): UptimeRobotMonitor { const lastIncident = toRecordOrNull(raw.lastIncident) return { - id: asNumber(raw.id) ?? 0, - friendlyName: asString(raw.friendlyName) ?? '', - url: asString(raw.url), - type: asEnum(raw.type), - status: asEnum(raw.status), - interval: asNumber(raw.interval), - timeout: asNumber(raw.timeout), - port: asNumber(raw.port), - keywordType: asEnum(raw.keywordType), - keywordValue: asString(raw.keywordValue), - httpMethodType: asEnum(raw.httpMethodType), - authType: asEnum(raw.authType), - successHttpResponseCodes: asArray(raw.successHttpResponseCodes).filter( + id: toNumberOrNull(raw.id) ?? 0, + friendlyName: toStringOrNull(raw.friendlyName) ?? '', + url: toStringOrNull(raw.url), + type: toStringOrNull(raw.type), + status: toStringOrNull(raw.status), + interval: toNumberOrNull(raw.interval), + timeout: toNumberOrNull(raw.timeout), + port: toNumberOrNull(raw.port), + keywordType: toStringOrNull(raw.keywordType), + keywordValue: toStringOrNull(raw.keywordValue), + httpMethodType: toStringOrNull(raw.httpMethodType), + authType: toStringOrNull(raw.authType), + successHttpResponseCodes: toArray(raw.successHttpResponseCodes).filter( (code): code is string => typeof code === 'string' ), - checkSSLErrors: asBoolean(raw.checkSSLErrors), - followRedirections: asBoolean(raw.followRedirections), - sslExpirationReminder: asBoolean(raw.sslExpirationReminder), - domainExpirationReminder: asBoolean(raw.domainExpirationReminder), - responseTimeThreshold: asNumber(raw.responseTimeThreshold), - currentStateDuration: asNumber(raw.currentStateDuration), - lastIncidentId: asString(raw.lastIncidentId), - groupId: asNumber(raw.groupId), - tags: asArray(raw.tags).map((tag) => { + checkSSLErrors: toBooleanOrNull(raw.checkSSLErrors), + followRedirections: toBooleanOrNull(raw.followRedirections), + sslExpirationReminder: toBooleanOrNull(raw.sslExpirationReminder), + domainExpirationReminder: toBooleanOrNull(raw.domainExpirationReminder), + responseTimeThreshold: toNumberOrNull(raw.responseTimeThreshold), + currentStateDuration: toNumberOrNull(raw.currentStateDuration), + lastIncidentId: toStringOrNull(raw.lastIncidentId), + groupId: toNumberOrNull(raw.groupId), + tags: toArray(raw.tags).map((tag) => { const t = toRecordOrNull(tag) ?? {} return { - id: asNumber(t.id) ?? 0, - name: asString(t.name) ?? '', - color: asString(t.color), + id: toNumberOrNull(t.id) ?? 0, + name: toStringOrNull(t.name) ?? '', + color: toStringOrNull(t.color), } }), - assignedAlertContacts: asArray(raw.assignedAlertContacts).map((contact) => { + assignedAlertContacts: toArray(raw.assignedAlertContacts).map((contact) => { const c = toRecordOrNull(contact) ?? {} return { - alertContactId: asNumber(c.alertContactId) ?? 0, - threshold: asNumber(c.threshold) ?? 0, - recurrence: asNumber(c.recurrence) ?? 0, + alertContactId: toNumberOrNull(c.alertContactId) ?? 0, + threshold: toNumberOrNull(c.threshold) ?? 0, + recurrence: toNumberOrNull(c.recurrence) ?? 0, } }), lastIncident: lastIncident ? { - id: asString(lastIncident.id) ?? '', - status: asEnum(lastIncident.status), - cause: asNumber(lastIncident.cause), - reason: asString(lastIncident.reason), - startedAt: asString(lastIncident.startedAt), - duration: asNumber(lastIncident.duration), + id: toStringOrNull(lastIncident.id) ?? '', + status: toStringOrNull(lastIncident.status), + cause: toNumberOrNull(lastIncident.cause), + reason: toStringOrNull(lastIncident.reason), + startedAt: toStringOrNull(lastIncident.startedAt), + duration: toNumberOrNull(lastIncident.duration), } : null, - createDateTime: asString(raw.createDateTime), + createDateTime: toStringOrNull(raw.createDateTime), } } export function mapMaintenanceWindow(raw: Raw): UptimeRobotMaintenanceWindow { return { - id: asNumber(raw.id) ?? 0, - userId: asNumber(raw.userId), - name: asString(raw.name) ?? '', - interval: asEnum(raw.interval), - date: asString(raw.date), - time: asString(raw.time), - duration: asNumber(raw.duration), - autoAddMonitors: asBoolean(raw.autoAddMonitors), - monitorIds: asArray(raw.monitorIds).filter((id): id is number => typeof id === 'number'), - days: asArray(raw.days).filter((day): day is number => typeof day === 'number'), - status: asEnum(raw.status), - created: asString(raw.created), + id: toNumberOrNull(raw.id) ?? 0, + userId: toNumberOrNull(raw.userId), + name: toStringOrNull(raw.name) ?? '', + interval: toStringOrNull(raw.interval), + date: toStringOrNull(raw.date), + time: toStringOrNull(raw.time), + duration: toNumberOrNull(raw.duration), + autoAddMonitors: toBooleanOrNull(raw.autoAddMonitors), + monitorIds: toArray(raw.monitorIds).filter((id): id is number => typeof id === 'number'), + days: toArray(raw.days).filter((day): day is number => typeof day === 'number'), + status: toStringOrNull(raw.status), + created: toStringOrNull(raw.created), } } export function mapAlertContact(raw: Raw): UptimeRobotAlertContact { const notify = raw.enableNotificationsFor return { - id: asNumber(raw.id) ?? 0, - friendlyName: asString(raw.friendlyName), - type: asEnum(raw.type), - value: asString(raw.value), - customValue: asString(raw.customValue), - status: asEnum(raw.status), + id: toNumberOrNull(raw.id) ?? 0, + friendlyName: toStringOrNull(raw.friendlyName), + type: toStringOrNull(raw.type), + value: toStringOrNull(raw.value), + customValue: toStringOrNull(raw.customValue), + status: toStringOrNull(raw.status), enableNotificationsFor: typeof notify === 'number' || typeof notify === 'string' ? notify : null, - sslExpirationReminder: asBoolean(raw.sslExpirationReminder), + sslExpirationReminder: toBooleanOrNull(raw.sslExpirationReminder), } } export function mapPsp(raw: Raw): UptimeRobotPsp { return { - id: asNumber(raw.id) ?? 0, - friendlyName: asString(raw.friendlyName) ?? '', - customDomain: asString(raw.customDomain), - isPasswordSet: asBoolean(raw.isPasswordSet), - monitorIds: asArray(raw.monitorIds).filter((id): id is number => typeof id === 'number'), - tagIds: asArray(raw.tagIds).filter((id): id is number => typeof id === 'number'), - monitorsCount: asNumber(raw.monitorsCount), - status: asEnum(raw.status), - urlKey: asString(raw.urlKey), - homepageLink: asString(raw.homepageLink), - gaCode: asString(raw.gaCode), - icon: asString(raw.icon), - logo: asString(raw.logo), - noIndex: asBoolean(raw.noIndex), - hideUrlLinks: asBoolean(raw.hideUrlLinks), - subscription: asBoolean(raw.subscription), + id: toNumberOrNull(raw.id) ?? 0, + friendlyName: toStringOrNull(raw.friendlyName) ?? '', + customDomain: toStringOrNull(raw.customDomain), + isPasswordSet: toBooleanOrNull(raw.isPasswordSet), + monitorIds: toArray(raw.monitorIds).filter((id): id is number => typeof id === 'number'), + tagIds: toArray(raw.tagIds).filter((id): id is number => typeof id === 'number'), + monitorsCount: toNumberOrNull(raw.monitorsCount), + status: toStringOrNull(raw.status), + urlKey: toStringOrNull(raw.urlKey), + homepageLink: toStringOrNull(raw.homepageLink), + gaCode: toStringOrNull(raw.gaCode), + icon: toStringOrNull(raw.icon), + logo: toStringOrNull(raw.logo), + noIndex: toBooleanOrNull(raw.noIndex), + hideUrlLinks: toBooleanOrNull(raw.hideUrlLinks), + subscription: toBooleanOrNull(raw.subscription), } } export function mapIncidentSummary(raw: Raw): UptimeRobotIncidentSummary { const monitor = toRecordOrNull(raw.monitor) ?? {} return { - id: asString(raw.id) ?? '', - status: asEnum(raw.status), - type: asEnum(raw.type), - cause: asNumber(raw.cause), - reason: asString(raw.reason), - monitorId: asNumber(monitor.id), - monitorName: asString(monitor.friendlyName), - commentsCount: asNumber(raw.commentsCount), - startedAt: asString(raw.startedAt), - resolvedAt: asString(raw.resolvedAt), - duration: asNumber(raw.duration), - includeInReports: asBoolean(raw.includeInReports), + id: toStringOrNull(raw.id) ?? '', + status: toStringOrNull(raw.status), + type: toStringOrNull(raw.type), + cause: toNumberOrNull(raw.cause), + reason: toStringOrNull(raw.reason), + monitorId: toNumberOrNull(monitor.id), + monitorName: toStringOrNull(monitor.friendlyName), + commentsCount: toNumberOrNull(raw.commentsCount), + startedAt: toStringOrNull(raw.startedAt), + resolvedAt: toStringOrNull(raw.resolvedAt), + duration: toNumberOrNull(raw.duration), + includeInReports: toBooleanOrNull(raw.includeInReports), } } export function mapIncidentDetail(raw: Raw): UptimeRobotIncidentDetail { const rootCause = toRecordOrNull(raw.rootCause) return { - id: asString(raw.id) ?? '', - status: asEnum(raw.status), - cause: asNumber(raw.cause), - reason: asString(raw.reason), - duration: asNumber(raw.duration), - startedAt: asString(raw.startedAt), - resolvedAt: asString(raw.resolvedAt), + id: toStringOrNull(raw.id) ?? '', + status: toStringOrNull(raw.status), + cause: toNumberOrNull(raw.cause), + reason: toStringOrNull(raw.reason), + duration: toNumberOrNull(raw.duration), + startedAt: toStringOrNull(raw.startedAt), + resolvedAt: toStringOrNull(raw.resolvedAt), rootCause: rootCause ? { - url: asString(rootCause.url), - httpResponseCode: asNumber(rootCause.httpResponseCode), - responseDownloadUrl: asString(rootCause.responseDownloadUrl), + url: toStringOrNull(rootCause.url), + httpResponseCode: toNumberOrNull(rootCause.httpResponseCode), + responseDownloadUrl: toStringOrNull(rootCause.responseDownloadUrl), } : null, } @@ -456,14 +437,14 @@ export function mapIncidentDetail(raw: Raw): UptimeRobotIncidentDetail { export function mapAccount(raw: Raw): UptimeRobotAccount { const subscription = toRecordOrNull(raw.activeSubscription) ?? {} return { - email: asString(raw.email), - fullName: asString(raw.fullName), - monitorsCount: asNumber(raw.monitorsCount), - monitorLimit: asNumber(raw.monitorLimit), - smsCredits: asNumber(raw.smsCredits), - plan: asString(subscription.plan), - subscriptionStatus: asString(subscription.status), - subscriptionExpiresAt: asString(subscription.expirationDate), + email: toStringOrNull(raw.email), + fullName: toStringOrNull(raw.fullName), + monitorsCount: toNumberOrNull(raw.monitorsCount), + monitorLimit: toNumberOrNull(raw.monitorLimit), + smsCredits: toNumberOrNull(raw.smsCredits), + plan: toStringOrNull(subscription.plan), + subscriptionStatus: toStringOrNull(subscription.status), + subscriptionExpiresAt: toStringOrNull(subscription.expirationDate), } } diff --git a/packages/utils/package.json b/packages/utils/package.json index 97ce4f54663..5492b3768af 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -22,6 +22,10 @@ "types": "./src/client-info.ts", "default": "./src/client-info.ts" }, + "./coerce": { + "types": "./src/coerce.ts", + "default": "./src/coerce.ts" + }, "./color": { "types": "./src/color.ts", "default": "./src/color.ts" diff --git a/packages/utils/src/coerce.test.ts b/packages/utils/src/coerce.test.ts new file mode 100644 index 00000000000..5275fa9de8f --- /dev/null +++ b/packages/utils/src/coerce.test.ts @@ -0,0 +1,49 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { toBooleanOrNull, toNumberOrNull, toStringOrNull } from './coerce.js' + +describe('toStringOrNull', () => { + it('returns the value when it is a string, including empty', () => { + expect(toStringOrNull('x')).toBe('x') + expect(toStringOrNull('')).toBe('') + }) + + it('returns null for every non-string', () => { + expect(toStringOrNull(1)).toBeNull() + expect(toStringOrNull(null)).toBeNull() + expect(toStringOrNull(undefined)).toBeNull() + expect(toStringOrNull(['x'])).toBeNull() + expect(toStringOrNull(new String('x'))).toBeNull() + }) +}) + +describe('toNumberOrNull', () => { + it('returns the value when it is a number, including 0', () => { + expect(toNumberOrNull(0)).toBe(0) + expect(toNumberOrNull(-1.5)).toBe(-1.5) + }) + + /* A typeof test, not a finiteness test — the TSDoc says so, so pin it. */ + it('passes NaN and the infinities through', () => { + expect(toNumberOrNull(Number.NaN)).toBeNaN() + expect(toNumberOrNull(Number.POSITIVE_INFINITY)).toBe(Number.POSITIVE_INFINITY) + }) + + it('returns null for a numeric string', () => { + expect(toNumberOrNull('1')).toBeNull() + }) +}) + +describe('toBooleanOrNull', () => { + it('returns the value when it is a boolean, including false', () => { + expect(toBooleanOrNull(false)).toBe(false) + expect(toBooleanOrNull(true)).toBe(true) + }) + + it('returns null for a truthy non-boolean', () => { + expect(toBooleanOrNull(1)).toBeNull() + expect(toBooleanOrNull('true')).toBeNull() + }) +}) diff --git a/packages/utils/src/coerce.ts b/packages/utils/src/coerce.ts new file mode 100644 index 00000000000..65f62c05ec3 --- /dev/null +++ b/packages/utils/src/coerce.ts @@ -0,0 +1,26 @@ +/** + * Coercions for reading a single value out of an untyped payload — the scalar + * counterparts to {@link toRecord} and {@link toArray} in `./object`. Each + * returns the value when it is already of that type and `null` otherwise, so a + * malformed field reads as absent rather than throwing at the read site. + */ + +/** Returns {@link value} when it is a string, `null` otherwise. */ +export function toStringOrNull(value: unknown): string | null { + return typeof value === 'string' ? value : null +} + +/** + * Returns {@link value} when it is a number, `null` otherwise. + * + * @remarks Deliberately a `typeof` test only: `NaN` and the infinities are + * numbers and pass through. Callers that need a finite value should say so. + */ +export function toNumberOrNull(value: unknown): number | null { + return typeof value === 'number' ? value : null +} + +/** Returns {@link value} when it is a boolean, `null` otherwise. */ +export function toBooleanOrNull(value: unknown): boolean | null { + return typeof value === 'boolean' ? value : null +} diff --git a/packages/utils/src/object.test.ts b/packages/utils/src/object.test.ts index 4818071fe43..72b6de09c5b 100644 --- a/packages/utils/src/object.test.ts +++ b/packages/utils/src/object.test.ts @@ -7,6 +7,7 @@ import { isPlainRecord, isRecordLike, sortObjectKeysDeep, + toArray, toRecord, toRecordOrNull, } from './object.js' @@ -124,3 +125,21 @@ describe('sortObjectKeysDeep', () => { ) }) }) + +describe('toArray', () => { + it('returns the original array on a hit, not a copy', () => { + const items = [1, 2] + expect(toArray(items)).toBe(items) + }) + + it('falls back to an empty array for a non-array', () => { + expect(toArray(undefined)).toEqual([]) + expect(toArray(null)).toEqual([]) + expect(toArray('nope')).toEqual([]) + expect(toArray({ length: 2 })).toEqual([]) + }) + + it('returns a fresh array on every miss, so callers cannot share one', () => { + expect(toArray(null)).not.toBe(toArray(null)) + }) +}) diff --git a/packages/utils/src/object.ts b/packages/utils/src/object.ts index 4c722bb1b49..20cb57c8779 100644 --- a/packages/utils/src/object.ts +++ b/packages/utils/src/object.ts @@ -67,6 +67,21 @@ export function toRecordOrNull(value: unknown): Record | null { return isRecordLike(value) ? value : null } +/** + * Coerces {@link value} to an array, falling back to an empty one. The array + * counterpart to {@link toRecord}, for the common `Array.isArray(v) ? v : []` + * shape when reading an untyped payload whose absence should read as "no items". + * + * @remarks Returns a fresh `[]` on every miss, so the result is never shared. A + * hit returns the original array rather than a copy, matching the inline form. + * The element type is the caller's assertion: nothing here inspects the members, + * so prefer the inline `Array.isArray` check where the source is already typed — + * that narrows, while this asserts. + */ +export function toArray(value: unknown): T[] { + return Array.isArray(value) ? (value as T[]) : [] +} + /** * Recursively sorts the keys of every plain object reachable from {@link value}, * preserving array order while recursing into array elements. Primitives and From f9e37f6f2882e1f9608d73bd899b4999899f8adc Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 19 Sep 2026 19:24:00 -0700 Subject: [PATCH 2/2] docs: document toArray and the scalar payload coercions --- .claude/rules/global.md | 2 ++ .cursor/rules/global.mdc | 2 ++ CLAUDE.md | 2 ++ 3 files changed, 6 insertions(+) diff --git a/.claude/rules/global.md b/.claude/rules/global.md index f38e09d9b07..d0ce371a49c 100644 --- a/.claude/rules/global.md +++ b/.claude/rules/global.md @@ -52,6 +52,8 @@ Use shared helpers from `@sim/utils` instead of writing inline implementations: - `omit(obj, keys)` from `@sim/utils/object` — remove keys from object - `filterUndefined(obj)` from `@sim/utils/object` — strip undefined-valued keys. Never write `Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined))` - `isRecordLike(value)` from `@sim/utils/object` — indexable-object guard. Never redeclare `typeof value === 'object' && value !== null && !Array.isArray(value)` +- `toRecord(value)` / `toRecordOrNull(value)` / `toArray(value)` from `@sim/utils/object` — coerce an untyped payload value to a record or array. Never inline `isRecordLike(v) ? v : {}` or `Array.isArray(v) ? v : []`. Where the source is already typed, keep the inline `Array.isArray` check: it narrows, while `toArray` asserts +- `toStringOrNull(value)` / `toNumberOrNull(value)` / `toBooleanOrNull(value)` from `@sim/utils/coerce` — read one scalar out of an untyped payload. Never declare a local one-liner that is byte-identical to one of these (`asString`, `getString`, `nullableString`, …). Keep a local helper that differs: one returning `undefined` rather than `null` changes the wire shape, and one adding `Number.isFinite` or a string parse is a stricter check these deliberately omit - `truncate(str, maxLength, suffix?)` from `@sim/utils/string` — safe string truncation with ellipsis - `escapeRegExp(value)` from `@sim/utils/string` — escape regex metacharacters. Never inline `replace(/[.*+?^${}()|[\]\\]/g, '\\$&')` - `compareStrings(left, right)` from `@sim/utils/string` — code-unit string comparator for hashes, fingerprints, and values compared across processes. Never `localeCompare` there diff --git a/.cursor/rules/global.mdc b/.cursor/rules/global.mdc index 052ceead52d..4862beed1db 100644 --- a/.cursor/rules/global.mdc +++ b/.cursor/rules/global.mdc @@ -55,6 +55,8 @@ Use shared helpers from `@sim/utils` instead of writing inline implementations: - `omit(obj, keys)` from `@sim/utils/object` — remove keys from object - `filterUndefined(obj)` from `@sim/utils/object` — strip undefined-valued keys. Never write `Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined))` - `isRecordLike(value)` from `@sim/utils/object` — indexable-object guard. Never redeclare `typeof value === 'object' && value !== null && !Array.isArray(value)` +- `toRecord(value)` / `toRecordOrNull(value)` / `toArray(value)` from `@sim/utils/object` — coerce an untyped payload value to a record or array. Never inline `isRecordLike(v) ? v : {}` or `Array.isArray(v) ? v : []`. Where the source is already typed, keep the inline `Array.isArray` check: it narrows, while `toArray` asserts +- `toStringOrNull(value)` / `toNumberOrNull(value)` / `toBooleanOrNull(value)` from `@sim/utils/coerce` — read one scalar out of an untyped payload. Never declare a local one-liner that is byte-identical to one of these (`asString`, `getString`, `nullableString`, …). Keep a local helper that differs: one returning `undefined` rather than `null` changes the wire shape, and one adding `Number.isFinite` or a string parse is a stricter check these deliberately omit - `truncate(str, maxLength, suffix?)` from `@sim/utils/string` — safe string truncation with ellipsis - `escapeRegExp(value)` from `@sim/utils/string` — escape regex metacharacters. Never inline `replace(/[.*+?^${}()|[\]\\]/g, '\\$&')` - `compareStrings(left, right)` from `@sim/utils/string` — code-unit string comparator for hashes, fingerprints, and values compared across processes. Never `localeCompare` there diff --git a/CLAUDE.md b/CLAUDE.md index bc41ebfae0c..29ca6ff4ad1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,8 @@ You are a professional software engineer. All code must follow best practices: a - `structuredClone(value)` — built-in deep clone; never `JSON.parse(JSON.stringify(...))` - `omit(obj, keys)` / `filterUndefined(obj)` from `@sim/utils/object` — object trimming; never `Object.fromEntries(Object.entries(...).filter(...))` - `isRecordLike(value)` from `@sim/utils/object` — never redeclare `typeof value === 'object' && value !== null && !Array.isArray(value)` + - `toRecord(value)` / `toRecordOrNull(value)` / `toArray(value)` from `@sim/utils/object` — coerce an untyped payload value to a record or array; never inline `isRecordLike(v) ? v : {}` or `Array.isArray(v) ? v : []`. Where the source is already typed, keep the inline `Array.isArray` check: it narrows, while `toArray` asserts + - `toStringOrNull(value)` / `toNumberOrNull(value)` / `toBooleanOrNull(value)` from `@sim/utils/coerce` — read one scalar out of an untyped payload; never declare a local one-liner byte-identical to one of these. Keep a local helper that differs: `undefined` instead of `null` changes the wire shape, and a `Number.isFinite` or string-parse variant is a stricter check these omit - `truncate(str, maxLength, suffix?)` from `@sim/utils/string` — never inline slice + ellipsis - `escapeRegExp(value)` from `@sim/utils/string` — never inline `replace(/[.*+?^${}()|[\]\\]/g, '\\$&')` - `compareStrings(left, right)` from `@sim/utils/string` — code-unit ordering for hashes, fingerprints, and cross-process comparisons; never `localeCompare` there