import { Shape, DurationShape } from '@servicenow/sdk-build-core' import type { Record, Database, Logger, ObjectShape, Factory, ShapeTransform } from '@servicenow/sdk-build-core' import { NowIdShape } from '../now-id-plugin' import { ModuleFunctionShape } from '../server-module-plugin' import { variableToCallExpression } from './record-to-shape' import { convertToNumber, convertRolesToString, DEFAULT_DELIVERY_TIME, parseString, getFulfillmentAutomationLevelFromDb, getFulfillmentAutomationLevelToDb, getAvailabilityFromDb, getAvailabilityToDb, getMobilePictureTypeFromDb, getMobilePictureTypeToDb, } from './utils' /** * Shared relationship definitions for both CatalogItem and CatalogItemRecordProducer * These relationships are identical since CatalogItemRecordProducer extends CatalogItem */ export const CatalogItemBaseRelationships = { //update sc_cat_item_user_criteria_mtom: { via: 'sc_cat_item', descendant: true, relationships: { user_criteria: { via: 'user_criteria', inverse: true, }, }, }, sc_cat_item_user_criteria_no_mtom: { via: 'sc_cat_item', descendant: true, relationships: { user_criteria: { via: 'user_criteria', inverse: true, }, }, }, io_set_item: { via: 'sc_cat_item', descendant: true, relationships: { item_option_new_set: { via: 'variable_set', inverse: true, // Required so fetchChildRecords recurses into the nested item_option_new relationship below. descendant: true, relationships: { // Fetches variable-set variables so dependentQuestion sys_ids can resolve to names in toShape. item_option_new: { via: 'variable_set', descendant: true, }, }, }, }, }, sc_cat_item_catalog: { via: 'sc_cat_item', descendant: true, relationships: { sc_catalog: { via: 'sc_catalog', inverse: true, }, }, }, sc_cat_item_category: { via: 'sc_cat_item', descendant: true, relationships: { sc_category: { via: 'sc_category', inverse: true, }, }, }, m2m_connected_content: { via: 'catalog_item', descendant: true, relationships: { topic: { via: 'topic', inverse: true, }, }, }, fx_price: { via: 'id', descendant: true, }, item_option_new: { via: 'cat_item', descendant: true, relationships: { question_choice: { via: 'question', descendant: true, relationships: { fx_price: { via: 'id', descendant: true, }, }, }, fx_price: { via: 'id', descendant: true, }, }, }, } as const /** * Shared coalesce definitions for both CatalogItem and CatalogItemRecordProducer * These define unique key combinations for M2M and related records */ export const CatalogItemBaseCoalesce = { sc_cat_item_user_criteria_mtom: { coalesce: ['sc_cat_item', 'user_criteria'] as [string, string], }, sc_cat_item_user_criteria_no_mtom: { coalesce: ['sc_cat_item', 'user_criteria'] as [string, string], }, io_set_item: { coalesce: ['sc_cat_item', 'variable_set'] as [string, string], }, sc_cat_item_catalog: { coalesce: ['sc_cat_item', 'sc_catalog'] as [string, string], }, sc_cat_item_category: { coalesce: ['sc_cat_item', 'sc_category'] as [string, string], }, m2m_connected_content: { coalesce: ['catalog_item', 'topic'] as [string, string], }, fx_price: { coalesce: ['id', 'field'] as [string, string], }, item_option_new: { coalesce: ['cat_item', 'variable_set', 'name'] as [string, string, string], }, question_choice: { coalesce: ['question', 'value'] as [string, string], }, } /** * Builds variables schema from item_option_new descendants. * Shared by CatalogItem, CatalogItemRecordProducer, and VariableSet. * * ownerRecordId identifies which record this schema is being built for: * - Omitted (CatalogItem/RecordProducer): only variables with no variable_set are "direct" — * variables belonging to an attached VariableSet are excluded, since that set owns them. * - Provided (VariableSet's own sys_id): variables pointing back at this VariableSet also count * as direct, since descendants here is scoped to this set's own children. * * Variables are sorted by order field (defaulting to 0 if empty) and empty names are skipped. */ export function buildVariablesSchema(descendants: Database, logger: Logger, ownerRecordId?: string) { const allVariables = descendants.query('item_option_new') const directVariables = allVariables .filter((v) => { const vsField = v.get('variable_set') const vsId = vsField?.ifString()?.getValue()?.trim() ?? vsField?.asRecord()?.getId()?.getValue()?.trim() ?? '' return vsId === '' || vsId === ownerRecordId }) .sort((a, b) => { const orderA = convertToNumber(a.get('order'), 0) const orderB = convertToNumber(b.get('order'), 0) return orderA - orderB }) const variablesSchema: globalThis.Record = {} for (const variable of directVariables) { const variableName = variable.get('name').ifString()?.getValue() if (!variableName || variableName.trim() === '') { continue } variablesSchema[variableName] = variableToCallExpression(variable, descendants, logger) } return variablesSchema } /** * Common field data extraction - fields with HIGH usage in BOTH CatalogItem AND CatalogItemRecordProducer * These fields appear on both forms and are frequently used */ export function buildCommonCatalogFields(descendants: Database) { const categories = descendants.query('sc_cat_item_category').map((m2m) => m2m.get('sc_category')) const availableFor = descendants.query('sc_cat_item_user_criteria_mtom').map((m2m) => m2m.get('user_criteria')) const notAvailableFor = descendants .query('sc_cat_item_user_criteria_no_mtom') .map((m2m) => m2m.get('user_criteria')) const variableSets = descendants .query('io_set_item') .map((m2m) => { return { variableSet: m2m.get('variable_set'), order: convertToNumber(m2m.get('order'), 0), } }) .sort((a, b) => a.order - b.order) const assignedTopics = descendants.query('m2m_connected_content').map((m2m) => m2m.get('topic')) return { categories, availableFor, notAvailableFor, variableSets, assignedTopics, } } /** * Transforms common catalog fields from record to shape (record → fluent) * These fields have HIGH usage in both CatalogItem and CatalogItemRecordProducer forms */ export function transformCatalogBaseFieldsToShape(record: Record, $: ShapeTransform, descendants: Database) { const { categories, availableFor, notAvailableFor, variableSets, assignedTopics } = buildCommonCatalogFields(descendants) return { $id: $.val(NowIdShape.from(record)), name: $, active: $.toBoolean().def(true), accessType: $.from('access_type').def('restricted'), assignedTopics: $.val(assignedTopics).def([]), availability: $.map((v: Shape) => getAvailabilityFromDb(v.toString().getValue())).def('desktopOnly'), availableFor: $.val(availableFor).def([]), catalogs: $.from('sc_catalogs') .map((v: Shape) => { const str = v.ifString()?.getValue()?.trim() return str ? str.split(',').map((s) => s.trim()) : [] }) .def([]), categories: $.from('category') .map((c: Shape) => { if (categories.length > 0) { return categories } return c.ifString()?.ifNotEmpty()?.getValue()?.trim() ? [c.toString()] : [] }) .def([]), checkedOut: $.from('checked_out') .map((c: Shape) => (c.ifString()?.isEmpty() ? '' : c.toBoolean())) .def(''), description: $.def(''), hideAttachment: $.from('no_attachment_v2').toBoolean().def(false), hideSaveAsDraft: $.from('no_save_as_draft').toBoolean().def(false), icon: $.def(''), mandatoryAttachment: $.from('mandatory_attachment').toBoolean().def(false), meta: $.map((v: Shape) => parseString(v)).def([]), mobilePicture: $.from('mobile_picture').def(''), notAvailableFor: $.val(notAvailableFor).def([]), order: $.map((c: Shape) => convertToNumber(c, 0)).def(0), owner: $.def(''), picture: $.def(''), roles: $.map((v: Shape) => parseString(v)).def([]), shortDescription: $.from('short_description').def(''), showVariableHelpOnLoad: $.from('show_variable_help_on_load').toBoolean().def(false), startClosed: $.from('start_closed').toBoolean().def(false), state: $.def(''), type: $.def('item'), useScLayout: $.from('use_sc_layout').toBoolean().def(true), variableSets: $.val(variableSets).def([]), version: $.map((c: Shape) => convertToNumber(c, 1)).def(1), view: $.def(''), visibleBundle: $.from('visible_bundle').toBoolean().def(true), visibleGuide: $.from('visible_guide').toBoolean().def(true), visibleStandalone: $.from('visible_standalone').toBoolean().def(true), hideAddToCart: $.from('no_cart_v2').toBoolean().def(false), hideAddToWishList: $.from('no_wishlist_v2').toBoolean().def(false), hideDeliveryTime: $.from('no_delivery_time_v2').toBoolean().def(false), hideQuantitySelector: $.from('no_quantity_v2').toBoolean().def(false), hideSP: $.from('hide_sp').toBoolean().def(false), image: $.def(''), model: $.def(''), mobilePictureType: $.from('mobile_picture_type') .map((v: Shape) => getMobilePictureTypeFromDb(v.toString().getValue())) .def('desktopPicture'), makeItemNonConversational: $.from('make_item_non_conversational').toBoolean().def(false), noSearch: $.from('no_search').toBoolean().def(false), } } /** * Transforms common catalog fields from shape to record (fluent → platform) * These fields have HIGH usage in both CatalogItem and CatalogItemRecordProducer forms */ export function transformCatalogItemBaseFieldsToRecord($: ShapeTransform) { return { name: $, active: $.def(true), access_type: $.from('accessType').def('restricted'), availability: $.from('availability') .map((v: Shape) => getAvailabilityToDb(v.toString().getValue())) .def('on_desktop'), category: $.from('categories') .map((v: Shape) => { const elements = v.ifArray()?.getElements() ?? [] if (elements.length === 0) { return undefined } const firstElement = elements[0] return firstElement?.ifString()?.getValue() || firstElement?.ifRecord()?.getId()?.getValue() }) .def(''), checked_out: $.from('checkedOut'), description: $.def('').toCdata(), icon: $.def(''), mandatory_attachment: $.from('mandatoryAttachment').def(false), meta: $.map((v: Shape) => v .ifArray() ?.getElements() .map((e: Shape) => e.getValue()) .join(',') ).def(''), mobile_picture: $.from('mobilePicture').def(''), no_attachment_v2: $.from('hideAttachment').def(false), no_save_as_draft: $.from('hideSaveAsDraft').def(false), order: $.def(0), owner: $.def(''), picture: $.def(''), roles: $.map(convertRolesToString).def(''), sc_catalogs: $.from('catalogs') .map((v: Shape) => v .ifArray() ?.map((r: Shape) => (r.ifRecord()?.getId() ?? r.toString()).getValue()) .join(',') ) .def([]), short_description: $.from('shortDescription').def(''), show_variable_help_on_load: $.from('showVariableHelpOnLoad').def(false), state: $.def(''), start_closed: $.from('startClosed').def(false), taxonomy_topic: $.from('assignedTopics') .map((v: Shape) => { const elements = v.ifArray()?.getElements() ?? [] if (elements.length === 0) { return undefined } const firstElement = elements[0] return firstElement?.ifString()?.getValue() || firstElement?.ifRecord()?.getId()?.getValue() }) .def(''), type: $.def('item'), use_sc_layout: $.from('useScLayout').def(true), version: $.def(1), view: $.def(''), visible_bundle: $.from('visibleBundle').def(true), visible_guide: $.from('visibleGuide').def(true), visible_standalone: $.from('visibleStandalone').def(true), no_cart_v2: $.from('hideAddToCart').def(false), no_wishlist_v2: $.from('hideAddToWishList').def(false), no_delivery_time_v2: $.from('hideDeliveryTime').def(false), no_quantity_v2: $.from('hideQuantitySelector').def(false), hide_sp: $.from('hideSP').def(false), image: $.def(''), model: $.def(''), mobile_picture_type: $.from('mobilePictureType') .map((v: Shape) => getMobilePictureTypeToDb(v.toString().getValue())) .def('use_desktop_picture'), make_item_non_conversational: $.from('makeItemNonConversational').def(false), no_search: $.from('noSearch').def(false), } } /** * CatalogItem-ONLY field transformations (LOW/RARE usage in CatalogItemRecordProducer) * These fields are not commonly shown on CatalogItemRecordProducer forms */ export function transformCatalogItemSpecificFieldsToShape(record: Record, $: ShapeTransform, descendants: Database) { const pricingDetails = descendants .query('fx_price', { id: record.getId().getValue(), }) .map((m2m) => { const amount = convertToNumber(m2m.get('amount'), 0) const currencyType = m2m.get('currency').getValue() const field = m2m.get('field').getValue() return { amount, currencyType, field } }) const rawDeliveryTimeValue = record.get('delivery_time').ifString()?.getValue() const deliveryTime = rawDeliveryTimeValue ? DurationShape.from(record, Shape.from(record, rawDeliveryTimeValue).asString()).getDuration() : {} return { // Pricing fields billable: $.toBoolean().def(false), cost: $.map((c: Shape) => convertToNumber(c, 0)).def(0), ignorePrice: $.from('ignore_price').toBoolean().def(true), omitPrice: $.from('omit_price').toBoolean().def(false), pricingDetails: $.val(pricingDetails).def([]), mobileHidePrice: $.from('mobile_hide_price').toBoolean().def(false), // Automation fields executionPlan: $.from('delivery_plan').def(''), flow: $.from('flow_designer_flow').def(''), fulfillmentAutomationLevel: $.from('fulfillment_automation_level') .map((v: Shape) => getFulfillmentAutomationLevelFromDb(v.toString().getValue())) .def('unspecified'), fulfillmentGroup: $.from('group').def(''), workflow: $.def(''), // Delivery fields deliveryTime: $.val(deliveryTime).def({}), displayPriceProperty: $.from('display_price_property').def('non_zero'), requestMethod: $.from('request_method') .map((v: Shape) => (v.ifString()?.isEmpty() ? undefined : v.getValue())) .def('order'), customCart: $.from('custom_cart').def(''), recurringFrequency: $.from('recurring_frequency').def(''), // Legacy fields (backward compatibility) noCart: $.from('no_cart').toBoolean().def(false), noOrder: $.from('no_order').toBoolean().def(false), noOrderNow: $.from('no_order_now').toBoolean().def(false), noProceedCheckout: $.from('no_proceed_checkout').toBoolean().def(false), noQuantity: $.from('no_quantity').toBoolean().def(false), // Additional fields vendor: $.def(''), location: $.def(''), } } /** * CatalogItem-ONLY field transformations for toRecord (fluent → platform) */ export function transformCatalogItemSpecificFieldsToRecord(arg: ObjectShape, $: ShapeTransform) { const rawDeliveryTime = arg.get('deliveryTime') const deliveryTime = rawDeliveryTime.isUndefined() ? DEFAULT_DELIVERY_TIME : DurationShape.from( rawDeliveryTime.getSource(), rawDeliveryTime.asObject().isDefined() ? rawDeliveryTime.asObject() : rawDeliveryTime.asString() ) .toString() .getValue() return { // Pricing billable: $.def(false), cost: $.def(0), ignore_price: $.from('ignorePrice').def(true), omit_price: $.from('omitPrice').def(false), mobile_hide_price: $.from('mobileHidePrice').def(false), // Automation fields delivery_plan: $.from('executionPlan').def(''), flow_designer_flow: $.from('flow').def(''), fulfillment_automation_level: $.from('fulfillmentAutomationLevel') .map((v: Shape) => getFulfillmentAutomationLevelToDb(v.toString().getValue())) .def('unspecified'), group: $.from('fulfillmentGroup').def(''), workflow: $.def(''), // Delivery delivery_time: $.val(deliveryTime).def({}), delivery_plan_script: $.from('deliveryPlanScript') .map((v: Shape) => v.if(ModuleFunctionShape)?.toString((n: string) => `${n}({{PARAMS}})`, ['current']) ?? v) .toCdata() .def(''), display_price_property: $.from('displayPriceProperty').def('non_zero'), entitlement_script: $.from('entitlementScript') .map((v: Shape) => v.if(ModuleFunctionShape)?.toString((n: string) => `${n}({{PARAMS}})`, ['current']) ?? v) .toCdata() .def(''), request_method: $.from('requestMethod') .map((v: Shape) => (v.ifString()?.getValue() === 'order' ? '' : v.getValue())) .def(''), custom_cart: $.from('customCart').def(''), recurring_frequency: $.from('recurringFrequency').def(''), // Legacy no_cart: $.from('noCart').def(false), no_order: $.from('noOrder').def(false), no_order_now: $.from('noOrderNow').def(false), no_proceed_checkout: $.from('noProceedCheckout').def(false), no_quantity: $.from('noQuantity').def(false), // Additional vendor: $.def(''), location: $.def(''), } } /** * Creates M2M records for catalogs, categories, variable sets, etc. * This logic is shared between CatalogItem and CatalogItemRecordProducer */ export async function createSharedM2MRecords( callExpression: Shape, arg: ObjectShape, parentRecord: Record, factory: Factory ) { const catalogs = arg.get('catalogs').ifArray()?.getElements() ?? [] const categories = arg.get('categories').ifArray()?.getElements() ?? [] const variableSets = arg.get('variableSets').ifArray()?.getElements() ?? [] const availableFor = arg.get('availableFor').ifArray()?.getElements() ?? [] const notAvailableFor = arg.get('notAvailableFor').ifArray()?.getElements() ?? [] const assignedTopics = arg.get('assignedTopics').ifArray()?.getElements() ?? [] const catalogsRecords: Record[] = [] for (const catalog of catalogs) { catalogsRecords.push( await factory.createRecord({ source: callExpression, table: 'sc_cat_item_catalog', properties: { sc_cat_item: parentRecord, sc_catalog: catalog.isString() ? catalog.getValue() : catalog, }, }) ) } const categoriesRecords: Record[] = [] for (const category of categories) { categoriesRecords.push( await factory.createRecord({ source: callExpression, table: 'sc_cat_item_category', properties: { sc_cat_item: parentRecord, sc_category: category.isString() ? category.getValue() : category, }, }) ) } const variableSetRecords: Record[] = [] for (const variableSet of variableSets) { const varObj = variableSet.asObject() variableSetRecords.push( await factory.createRecord({ source: callExpression, table: 'io_set_item', properties: { sc_cat_item: parentRecord, variable_set: varObj.get('variableSet').isString() ? varObj.get('variableSet').getValue() : varObj.get('variableSet'), order: (() => { const orderShape = varObj.get('order') return orderShape.ifString()?.isEmpty() || orderShape.isUndefined() ? 100 : orderShape.toNumber().getValue() })(), }, }) ) } const availableForRecords: Record[] = [] for (const item of availableFor) { availableForRecords.push( await factory.createRecord({ source: callExpression, table: 'sc_cat_item_user_criteria_mtom', properties: { sc_cat_item: parentRecord, user_criteria: item.isString() ? item.getValue() : item, }, }) ) } const notAvailableForRecords: Record[] = [] for (const item of notAvailableFor) { notAvailableForRecords.push( await factory.createRecord({ source: callExpression, table: 'sc_cat_item_user_criteria_no_mtom', properties: { sc_cat_item: parentRecord, user_criteria: item.isString() ? item.getValue() : item, }, }) ) } const assignedTopicsRecords: Record[] = [] for (const assignedTopic of assignedTopics) { assignedTopicsRecords.push( await factory.createRecord({ source: callExpression, table: 'm2m_connected_content', properties: { content_type: '98f9a16553622010069addeeff7b1248', // catalog item is fixed catalog_item: parentRecord, topic: assignedTopic.isString() ? assignedTopic.getValue() : assignedTopic, source: 'user', order: '100', }, }) ) } return { catalogsRecords, categoriesRecords, variableSetRecords, availableForRecords, notAvailableForRecords, assignedTopicsRecords, } }