/** * Customer and project attributes synchronization module */ import { getCustomerAttributes, getProjectAttributes, listProjects, updateProjectAttribute } from '../api.js'; import { writeFileSafe, customerAttributesPath, customerAttributesMapPath, customerAttributesBackupPath } from '../fsutil.js'; import path from 'path'; import fs from 'fs-extra'; import yaml from 'js-yaml'; import { patchYamlToPyyaml } from '../format/yaml-patch.js'; import { isJsonValueType, normalizeJsonValueForStorage, jsonValuesEqual } from './json-attr-utils.js'; import type { AxiosInstance } from 'axios'; import type { CustomerConfig } from '../types.js'; /** * Save customer attributes to YAML format and return content for hashing */ export async function saveCustomerAttributes( client: AxiosInstance, customer: CustomerConfig, verbose: boolean = false ): Promise { if (verbose) console.log(`šŸ” Fetching customer attributes for ${customer.idn}...`); try { const response = await getCustomerAttributes(client, true); // Include hidden attributes // API returns { groups: [...], attributes: [...] } // We only want the attributes array in the expected format const attributes = response.attributes || response; if (verbose) console.log(`šŸ“¦ Found ${Array.isArray(attributes) ? attributes.length : 'invalid'} attributes`); // Create ID mapping for push operations (separate from YAML) const idMapping: Record = {}; // Transform attributes to match reference format exactly (no ID fields) const cleanAttributes = Array.isArray(attributes) ? attributes.map(attr => { // Store ID mapping for push operations if (attr.id) { idMapping[attr.idn] = attr.id; } // Coerce JSON-typed values to a STRING. The API can return the value // as a parsed object for `value_type: json` attributes; if we let // yaml.dump serialize that as a YAML structure the next push sends // `{"value": {...}}` instead of `{"value": "..."}` and the Workflow // Builder canvas breaks. See src/sync/json-attr-utils.ts for the // full rationale. let processedValue = attr.value; if (isJsonValueType(attr.value_type)) { processedValue = normalizeJsonValueForStorage(attr.value); } else if (typeof attr.value === 'string' && attr.value.startsWith('[{') && attr.value.endsWith('}]')) { // Legacy: reformat array-of-objects JSON strings for readability try { const parsed = JSON.parse(attr.value); processedValue = JSON.stringify(parsed, null, 0); // compact, valid JSON } catch (e) { processedValue = attr.value; } } const cleanAttr: any = { idn: attr.idn, value: processedValue, title: attr.title || "", description: attr.description || "", group: attr.group || "", is_hidden: attr.is_hidden, possible_values: attr.possible_values || [], value_type: `__ENUM_PLACEHOLDER_${attr.value_type}__` }; return cleanAttr; }) : []; const attributesYaml = { attributes: cleanAttributes }; // Emit YAML without folding/wrapping; patchYamlToPyyaml handles long-line // wrapping and converts JSON-like double-quoted values to single-quoted // (so strings containing `"` stay valid YAML on reload). let yamlContent = yaml.dump(attributesYaml, { indent: 2, quotingType: '"', forceQuotes: false, lineWidth: -1, noRefs: true, sortKeys: false, flowLevel: -1, }); // Post-process to fix enum format yamlContent = yamlContent.replace(/__ENUM_PLACEHOLDER_(\w+)__/g, '!enum "AttributeValueTypes.$1"'); // Convert JSON-like double-quoted values to single-quoted and wrap long lines yamlContent = patchYamlToPyyaml(yamlContent); // Save all files: attributes.yaml, ID mapping, and backup for diff tracking await writeFileSafe(customerAttributesPath(customer.idn), yamlContent); await writeFileSafe(customerAttributesMapPath(customer.idn), JSON.stringify(idMapping, null, 2)); await writeFileSafe(customerAttributesBackupPath(customer.idn), yamlContent); if (verbose) { console.log(`āœ“ Saved customer attributes to ${customerAttributesPath(customer.idn)}`); console.log(`āœ“ Saved attribute ID mapping to ${customerAttributesMapPath(customer.idn)}`); console.log(`āœ“ Created attributes backup for diff tracking`); } // Return content for hash calculation return yamlContent; } catch (error) { console.error(`āŒ Failed to save customer attributes for ${customer.idn}:`, error); throw error; } } /** * Save project attributes to YAML format in project directory */ export async function saveProjectAttributes( client: AxiosInstance, customer: CustomerConfig, projectId: string, projectIdn: string, verbose: boolean = false ): Promise { if (verbose) console.log(` šŸ” Fetching project attributes for ${projectIdn}...`); try { const response = await getProjectAttributes(client, projectId, true); // Include hidden attributes // API returns { groups: [...], attributes: [...] } const attributes = response.attributes || response; if (verbose) console.log(` šŸ“¦ Found ${Array.isArray(attributes) ? attributes.length : 0} project attributes`); if (!Array.isArray(attributes) || attributes.length === 0) { if (verbose) console.log(` ℹ No project attributes found for ${projectIdn}`); return; } // Create ID mapping for push operations const idMapping: Record = {}; // Transform attributes to match format (no ID fields) const cleanAttributes = attributes.map(attr => { // Store ID mapping if (attr.id) { idMapping[attr.idn] = attr.id; } // Coerce JSON-typed values to a STRING. See json-attr-utils.ts for // why this matters (Workflow Builder canvas blank-screen bug). let processedValue = attr.value; if (isJsonValueType(attr.value_type)) { processedValue = normalizeJsonValueForStorage(attr.value); } else if (typeof attr.value === 'string' && attr.value.startsWith('[{') && attr.value.endsWith('}]')) { try { const parsed = JSON.parse(attr.value); processedValue = JSON.stringify(parsed, null, 0); } catch (e) { processedValue = attr.value; } } return { idn: attr.idn, value: processedValue, title: attr.title || "", description: attr.description || "", group: attr.group || "", is_hidden: attr.is_hidden, possible_values: attr.possible_values || [], value_type: `__ENUM_PLACEHOLDER_${attr.value_type}__` }; }); const attributesYaml = { attributes: cleanAttributes }; // Emit YAML without folding/wrapping; patchYamlToPyyaml handles long-line // wrapping and converts JSON-like double-quoted values to single-quoted. let yamlContent = yaml.dump(attributesYaml, { indent: 2, quotingType: '"', forceQuotes: false, lineWidth: -1, noRefs: true, sortKeys: false, flowLevel: -1, }); yamlContent = yamlContent.replace(/__ENUM_PLACEHOLDER_(\w+)__/g, '!enum "AttributeValueTypes.$1"'); yamlContent = patchYamlToPyyaml(yamlContent); // Save to project directory const customerDir = path.join(process.cwd(), 'newo_customers', customer.idn); const projectDir = path.join(customerDir, 'projects', projectIdn); await fs.ensureDir(projectDir); const attributesFile = path.join(projectDir, 'attributes.yaml'); const attributesMapFile = path.join(customerDir, '.newo', customer.idn, `project_${projectIdn}_attributes-map.json`); await writeFileSafe(attributesFile, yamlContent); await fs.ensureDir(path.dirname(attributesMapFile)); await writeFileSafe(attributesMapFile, JSON.stringify(idMapping, null, 2)); if (verbose) { console.log(` āœ“ Saved project attributes to projects/${projectIdn}/attributes.yaml`); console.log(` āœ“ Saved attribute ID mapping`); } } catch (error: any) { if (verbose) console.error(` āŒ Failed to fetch project attributes for ${projectIdn}:`, error.message); } } /** * Pull all project attributes for a customer */ export async function pullAllProjectAttributes( client: AxiosInstance, customer: CustomerConfig, verbose: boolean = false ): Promise { if (verbose) console.log(`\nšŸ“‹ Fetching project attributes...`); try { // Get all projects for this customer const projects = await listProjects(client); if (verbose) console.log(`āœ“ Found ${projects.length} projects\n`); for (const project of projects) { await saveProjectAttributes(client, customer, project.id, project.idn, verbose); } if (verbose) console.log(`\nāœ… Completed project attributes sync for ${projects.length} projects\n`); } catch (error) { console.error(`āŒ Failed to pull project attributes:`, error); throw error; } } /** * Push modified project attributes for a specific project */ export async function pushProjectAttributes( client: AxiosInstance, customer: CustomerConfig, projectId: string, projectIdn: string, verbose: boolean = false ): Promise { const customerDir = path.join(process.cwd(), 'newo_customers', customer.idn); const attributesFile = path.join(customerDir, 'projects', projectIdn, 'attributes.yaml'); const attributesMapFile = path.join(customerDir, '.newo', customer.idn, `project_${projectIdn}_attributes-map.json`); // Check if attributes file exists if (!await fs.pathExists(attributesFile)) { if (verbose) console.log(` ℹ No project attributes file for ${projectIdn}`); return 0; } // Load local attributes // Read as text and replace the custom !enum tags before parsing let attributesContent = await fs.readFile(attributesFile, 'utf-8'); // Replace custom enum tags with the actual value attributesContent = attributesContent.replace(/!enum "AttributeValueTypes\.(\w+)"/g, '$1'); const localData = yaml.load(attributesContent) as { attributes: any[] }; const localAttributes = localData.attributes || []; // Load ID mapping if (!await fs.pathExists(attributesMapFile)) { if (verbose) console.log(` ⚠ No ID mapping found for project ${projectIdn}, skipping push`); return 0; } const idMapping = JSON.parse(await fs.readFile(attributesMapFile, 'utf-8')) as Record; // Fetch current remote attributes for comparison const remoteResponse = await getProjectAttributes(client, projectId, true); const remoteAttributes = remoteResponse.attributes || []; // Create map of remote attributes by IDN const remoteMap = new Map(); remoteAttributes.forEach(attr => remoteMap.set(attr.idn, attr)); let updatedCount = 0; // Check each local attribute for changes for (const localAttr of localAttributes) { const attributeId = idMapping[localAttr.idn]; if (!attributeId) { if (verbose) console.log(` ⚠ No ID mapping for attribute ${localAttr.idn}, skipping`); continue; } const remoteAttr = remoteMap.get(localAttr.idn); if (!remoteAttr) { if (verbose) console.log(` ⚠ Attribute ${localAttr.idn} not found remotely, skipping`); continue; } // Value type is already parsed (we removed !enum tags above) const valueType = localAttr.value_type; const isJson = isJsonValueType(valueType); // Check if value changed. // For JSON-typed values, compare canonical (compact) JSON so that // pretty- vs compact-printed forms don't register as changes and // string vs object representations compare equal. For everything // else, fall back to the existing String() comparison (which still // preserves 0, false, "" via ??). const valuesAreEqual = isJson ? jsonValuesEqual(localAttr.value, remoteAttr.value) : String(localAttr.value ?? '') === String(remoteAttr.value ?? ''); if (!valuesAreEqual) { if (verbose) console.log(` šŸ”„ Updating project attribute: ${localAttr.idn}`); try { // Always send JSON-typed values as a STRING. If the API or our // YAML loader handed us an object, the platform stores it // differently from the original string and the Workflow Builder // canvas blanks out. const valueToSend = isJson ? normalizeJsonValueForStorage(localAttr.value) : localAttr.value; const attributeToUpdate = { id: attributeId, idn: localAttr.idn, value: valueToSend, title: localAttr.title, description: localAttr.description, group: localAttr.group, is_hidden: localAttr.is_hidden, possible_values: localAttr.possible_values, value_type: valueType }; await updateProjectAttribute(client, projectId, attributeToUpdate); if (verbose) console.log(` āœ… Updated: ${localAttr.idn} (${localAttr.title})`); updatedCount++; } catch (error: any) { const errorDetail = error.response?.data || error.message; console.error(` āŒ Failed to update ${localAttr.idn}: ${JSON.stringify(errorDetail)}`); if (verbose) { console.error(` API response:`, error.response?.status, error.response?.statusText); console.error(` Endpoint tried: PUT /api/v1/project/attributes/${attributeId}`); } } } else if (verbose) { console.log(` āœ“ No changes: ${localAttr.idn}`); } } return updatedCount; } /** * Push all modified project attributes for all projects */ export async function pushAllProjectAttributes( client: AxiosInstance, customer: CustomerConfig, projectsMap: Record, verbose: boolean = false ): Promise { if (verbose) console.log(`\nšŸ“‹ Checking project attributes for changes...`); let totalUpdated = 0; for (const [projectIdn, projectInfo] of Object.entries(projectsMap)) { if (!projectIdn) continue; // Skip empty project idn (legacy format) if (verbose) console.log(`\n šŸ“ Project: ${projectIdn}`); const updated = await pushProjectAttributes( client, customer, projectInfo.projectId, projectInfo.projectIdn || projectIdn, verbose ); totalUpdated += updated; } // Always show summary if changes were made if (totalUpdated > 0) { console.log(`\nāœ… Updated ${totalUpdated} project attribute(s)`); } else { if (verbose) console.log(`\nāœ“ No project attribute changes to push`); } return totalUpdated; }