From d58541f96533135907952b0301c1fd10690e6b9c Mon Sep 17 00:00:00 2001 From: Jorge Date: Wed, 17 Dec 2025 11:55:10 -0500 Subject: [PATCH 1/2] feat: new ct and bluestone scripts --- .env.example | 7 + .husky/pre-commit | 2 +- src/clients/bluestone.ts | 171 ++++++++++- src/clients/conscia.ts | 2 +- src/environment.ts | 9 +- src/main.ts | 38 ++- src/scripts/bluestone/export-attributes.ts | 31 ++ src/scripts/bluestone/import-attributes.ts | 111 ++++++++ .../commercetools/export-custom-types.ts | 26 ++ .../commercetools/import-custom-types.ts | 140 +++++++++ src/types/bluestone/bluestone.types.ts | 269 ++++++++++++++++++ src/types/bluestone/index.ts | 7 + src/utils/bluestone/attribute-mapper.ts | 129 +++++++++ src/utils/bluestone/group-manager.ts | 132 +++++++++ src/utils/bluestone/import-orchestrator.ts | 153 ++++++++++ .../commercetools/get-all-custom-types.ts | 35 +++ 16 files changed, 1243 insertions(+), 19 deletions(-) create mode 100644 src/scripts/bluestone/export-attributes.ts create mode 100644 src/scripts/bluestone/import-attributes.ts create mode 100644 src/scripts/commercetools/export-custom-types.ts create mode 100644 src/scripts/commercetools/import-custom-types.ts create mode 100644 src/types/bluestone/bluestone.types.ts create mode 100644 src/types/bluestone/index.ts create mode 100644 src/utils/bluestone/attribute-mapper.ts create mode 100644 src/utils/bluestone/group-manager.ts create mode 100644 src/utils/bluestone/import-orchestrator.ts create mode 100644 src/utils/commercetools/get-all-custom-types.ts diff --git a/.env.example b/.env.example index 31bc313..bc9bd9f 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,13 @@ CONTENTFUL_CONTENT_TYPE= BLUESTONE_CLIENT_ID= BLUESTONE_CLIENT_SECRET= BLUESTONE_AUTH_URL=https://idp.test.bluestonepim.com/op/token +BLUESTONE_GET_ATTRIBUTES_URL= +BLUESTONE_MANAGEMENT_API_URL= +BLUESTONE_ATTRIBUTE_GROUPS_URL= +BLUESTONE_DEFINITIONS_URL= +BLUESTONE_API_KEY= +BLUESTONE_API_CONTEXT= + ALGOLIA_APP_ID= ALGOLIA_API_KEY= diff --git a/.husky/pre-commit b/.husky/pre-commit index c27d889..2312dc5 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1 @@ -lint-staged +npx lint-staged diff --git a/src/clients/bluestone.ts b/src/clients/bluestone.ts index acb0401..2af3aee 100644 --- a/src/clients/bluestone.ts +++ b/src/clients/bluestone.ts @@ -1,23 +1,180 @@ import { environment } from '../environment' +import type { + BluestoneApiResponse, + BluestoneAttributeCreateRequest, + BluestoneAttributeGroupRequest, +} from '../types/bluestone/bluestone.types' -const getToken = async () => { +const getToken = async (): Promise<{ + access_token: string + token_type: string +}> => { const response = await fetch(environment.bluestone.authUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, body: new URLSearchParams({ client_id: environment.bluestone.clientId, client_secret: environment.bluestone.clientSecret, grant_type: 'client_credentials', }), - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Accept: 'application/json', - }, + }) + + if (!response.ok) { + throw new Error(`Failed to get token: ${response.status} ${response.statusText}`) + } + + return response.json() as Promise<{ + access_token: string + token_type: string + }> +} + +const getAuthHeaders = async () => { + const token = await getToken() + return { + Authorization: `${token.token_type} ${token.access_token}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + } +} + +const getAttributes = async () => { + const items: unknown[] = [] + const itemsOnPage = 50 + let pageNo = 1 + + while (true) { + const response = await fetch( + `${environment.bluestone.getAttributesUrl}?pageNo=${pageNo}&itemsOnPage=${itemsOnPage}`, + { + headers: { + 'x-api-key': environment.bluestone.apiKey, + Accept: 'application/json', + context: environment.bluestone.context, + }, + }, + ) + + const json = await response.json() + if (!response.ok) throw new Error(`API error: ${JSON.stringify(json)}`) + + const data = json as { results?: unknown[] } + if (!data.results) throw new Error('Unexpected response shape') + + if (data.results.length === 0) break + + items.push(...data.results) + if (data.results.length < itemsOnPage) break + pageNo++ + } + + return items +} + +const getAttributeGroups = async () => { + const groups: Array<{ id: string; name: string; number: string }> = [] + const pageSize = 1000 + let page = 0 + + do { + const headers = await getAuthHeaders() + const response = await fetch(`${environment.bluestone.attributeGroupsUrl}?page=${page}&pageSize=${pageSize}`, { + method: 'GET', + headers: { ...headers, context: environment.bluestone.context }, + }) + + if (!response.ok) { + const text = await response.text() + throw new Error(`Failed to get groups: ${response.status} - ${text}`) + } + + const json = (await response.json()) as { data?: unknown[] } + const pageGroups = (json.data || []) as typeof groups + + groups.push(...pageGroups) + page++ + + // If we got less than pageSize, we've reached the end + if (pageGroups.length < pageSize) break + // biome-ignore lint/correctness/noConstantCondition: pagination loop + } while (true) + + return groups +} + +const createAttributeGroup = async (group: BluestoneAttributeGroupRequest): Promise => { + const headers = await getAuthHeaders() + const response = await fetch(environment.bluestone.attributeGroupsUrl, { method: 'POST', + headers, + body: JSON.stringify(group), }) - const data = await response.json() - return data + if (!response.ok) { + const error = (await response.json().catch(() => ({}))) as { + conflictingEntities?: Array<{ entityId: string }> + } + + // Handle 409 Conflict - group already exists + if (response.status === 409) { + const existingId = error.conflictingEntities?.[0]?.entityId || 'unknown' + console.log(` āš ļø Group "${group.name}" already exists (${existingId})`) + return { + success: true, + resourceId: existingId, + data: { alreadyExists: true }, + } + } + + throw new Error(`Failed to create group: ${response.status} - ${JSON.stringify(error)}`) + } + + return { + success: true, + resourceId: response.headers.get('resource-id'), + data: await response.json().catch(() => ({})), + } +} + +const createAttribute = async (attribute: BluestoneAttributeCreateRequest): Promise => { + const headers = await getAuthHeaders() + const response = await fetch(environment.bluestone.definitionsUrl, { + method: 'POST', + headers, + body: JSON.stringify(attribute), + }) + + if (!response.ok) { + const error = (await response.json().catch(() => ({}))) as { + conflictingEntities?: Array<{ entityId: string }> + } + + // Handle 409 Conflict + if (response.status === 409) { + return { + success: true, + resourceId: error.conflictingEntities?.[0]?.entityId || 'unknown', + data: { alreadyExists: true }, + } + } + + throw new Error(`Failed to create attribute: ${response.status} - ${JSON.stringify(error)}`) + } + + return { + success: true, + resourceId: response.headers.get('resource-id'), + data: await response.json().catch(() => ({})), + } } export const bluestoneClient = { getToken, + getAttributes, + getAttributeGroups, + createAttributeGroup, + createAttribute, } diff --git a/src/clients/conscia.ts b/src/clients/conscia.ts index a35bf46..ca5bd02 100644 --- a/src/clients/conscia.ts +++ b/src/clients/conscia.ts @@ -188,7 +188,7 @@ const importEnvironment = async ({ preserveSecrets: boolean preserveEnvironmentVariables: boolean }) => { - const fullPath = resolve(__dirname, '../../outputs/conscia/export-environment.json') + const fullPath = resolve(__dirname, '../outputs/conscia/export-environment.json') const config = (await import(fullPath)) as unknown as ConsciaEnvironmentConfig const jsonBody = { diff --git a/src/environment.ts b/src/environment.ts index 2ef80cb..92ceb28 100644 --- a/src/environment.ts +++ b/src/environment.ts @@ -12,7 +12,14 @@ export const environment = { bluestone: { clientId: process.env.BLUESTONE_CLIENT_ID ?? '', clientSecret: process.env.BLUESTONE_CLIENT_SECRET ?? '', - authUrl: process.env.BLUESTONE_AUTH_URL ?? 'https://idp.test.bluestonepim.com/op/token', + authUrl: process.env.BLUESTONE_AUTH_URL ?? 'https://idp-us.bluestonepim.com/op/token', + getAttributesUrl: process.env.BLUESTONE_GET_ATTRIBUTES_URL ?? 'https://idp-us.bluestonepim.com/v1/attributes', + managementApiUrl: process.env.BLUESTONE_MANAGEMENT_API_URL ?? 'https://api-us.bluestonepim.com/pim', + attributeGroupsUrl: + process.env.BLUESTONE_ATTRIBUTE_GROUPS_URL ?? 'https://api-us.bluestonepim.com/pim/attributeGroups', + definitionsUrl: process.env.BLUESTONE_DEFINITIONS_URL ?? 'https://api-us.bluestonepim.com/pim/definitions', + apiKey: process.env.BLUESTONE_API_KEY ?? '', + context: process.env.BLUESTONE_API_CONTEXT ?? 'en', }, algolia: { appId: process.env.ALGOLIA_APP_ID ?? '', diff --git a/src/main.ts b/src/main.ts index 9810972..21ddf8a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, writeFile } from 'node:fs' +import { existsSync, mkdirSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { select } from '@inquirer/prompts' import { deepReadDir } from './utils' @@ -12,7 +12,10 @@ const bootstrap = async () => { const service = await select({ message: 'Select the service you want to use', - choices: [...services].map(service => ({ name: service, value: service })), + choices: [...services].map(service => ({ + name: service, + value: service, + })), }) const availableScripts = sanitizedScripts.filter(script => script.includes(service)) @@ -20,30 +23,47 @@ const bootstrap = async () => { const script = await select({ message: 'Select the script you want to run', - choices: availableScriptsNoExt.map(script => ({ name: script, value: script })), + choices: availableScriptsNoExt.map(script => ({ + name: script, + value: script, + })), }) const scriptPath = scriptPaths.find(scriptPath => scriptPath.includes(script)) try { const scriptFunction = await import(`${scriptPath}`) - const result = await scriptFunction.default() - if (typeof result !== 'undefined') { - console.log(result) + if (typeof result !== 'undefined' && result !== null) { + const dataToSave = result.__skipLog && result.data !== undefined ? result.data : result + + if (!result.__skipLog) { + const isLargeArray = Array.isArray(result) && result.length > 20 + if (isLargeArray) { + console.log(`\nšŸ“¦ Result: ${result.length} items`) + } else { + console.log(result) + } + } const path = `./src/outputs/${service}/${script}.json` const folderPath = path.split('/').slice(0, -1).join('/') if (!existsSync(folderPath)) mkdirSync(folderPath, { recursive: true }) - writeFile(path, JSON.stringify(result, null, 2), err => { - if (err) console.log(err) - }) + try { + writeFileSync(path, JSON.stringify(dataToSave, null, 2)) + console.log(`\nāœ… Saved: ${path}`) + } catch (err) { + console.error('\nāŒ Failed to save:', err) + } } + + process.exit(0) } catch (err) { console.error(err) + process.exit(1) } } bootstrap() diff --git a/src/scripts/bluestone/export-attributes.ts b/src/scripts/bluestone/export-attributes.ts new file mode 100644 index 0000000..c7c3702 --- /dev/null +++ b/src/scripts/bluestone/export-attributes.ts @@ -0,0 +1,31 @@ +import { bluestoneClient } from '../../clients/bluestone' + +export default async () => { + try { + console.log('\nšŸ”· Bluestone Attributes Export') + console.log('==============================\n') + + const attributes = await bluestoneClient.getAttributes() + + console.log(`āœ… Exported ${attributes.length} attributes`) + + // Show sample (first 10) + if (attributes.length > 0) { + console.log('\nšŸ“‹ Sample:') + for (const a of (attributes as Array<{ name?: string }>).slice(0, 10)) { + console.log(` - ${a.name}`) + } + if (attributes.length > 10) { + console.log(` ... and ${attributes.length - 10} more`) + } + } + + console.log('\nšŸ’¾ Saved to: outputs/bluestone/export-attributes.json') + + // Return without logging full content + return { __skipLog: true, data: attributes } + } catch (error) { + console.error('\nšŸ’„ Export failed:', error) + return null + } +} diff --git a/src/scripts/bluestone/import-attributes.ts b/src/scripts/bluestone/import-attributes.ts new file mode 100644 index 0000000..b968f38 --- /dev/null +++ b/src/scripts/bluestone/import-attributes.ts @@ -0,0 +1,111 @@ +import { resolve } from 'node:path' +import { confirm } from '@inquirer/prompts' +import { bluestoneClient } from '../../clients/bluestone' +import type { BluestoneAttribute } from '../../types/bluestone/bluestone.types' +import { executeCompleteImport } from '../../utils/bluestone/import-orchestrator' + +export default async () => { + try { + console.log('\nšŸ”· Bluestone Attributes Import') + console.log('==============================\n') + + console.log('šŸ›”ļø Safe Mode: Only adds missing attributes, never deletes\n') + + // Load data + const fullPath = resolve(__dirname, '../../outputs/bluestone/export-attributes.json') + const importedData = (await import(fullPath)) as unknown as { + default: BluestoneAttribute[] + } + const exportedAttrs = importedData.default || (importedData as unknown as BluestoneAttribute[]) + const existingAttrs = (await bluestoneClient.getAttributes()) as BluestoneAttribute[] + + console.log(`āœ“ ${exportedAttrs.length} exported, ${existingAttrs.length} existing`) + + // Helper to check existence by number OR name + const exists = (exported: BluestoneAttribute) => + existingAttrs.some(e => e.number === exported.number || e.name.toLowerCase() === exported.name.toLowerCase()) + + const toKeep = existingAttrs.filter(e => + exportedAttrs.some(exp => e.number === exp.number || e.name.toLowerCase() === exp.name.toLowerCase()), + ) + const extra = existingAttrs.filter( + e => !exportedAttrs.some(exp => e.number === exp.number || e.name.toLowerCase() === exp.name.toLowerCase()), + ) + const toCreate = exportedAttrs.filter(e => !exists(e)) + + // Preview attributes + console.log('\nšŸ“‹ Attributes:') + console.log(` šŸ“Œ Match (preserved): ${toKeep.length}`) + console.log(` šŸ“‹ Extra (preserved): ${extra.length}`) + console.log(` āž• To create: ${toCreate.length}`) + + if (toCreate.length > 0) { + console.log('\n✨ Will create:') + for (const a of toCreate.slice(0, 5)) { + console.log(` - ${a.name} (${a.number})`) + } + if (toCreate.length > 5) console.log(` ... and ${toCreate.length - 5} more`) + } + + // Check groups + const uniqueGroups = [ + ...new Map(exportedAttrs.map(a => [a.groupNumber, { name: a.groupName, number: a.groupNumber }])).values(), + ] + + let existingGroups: Array<{ number: string; name: string }> = [] + try { + const groups = await bluestoneClient.getAttributeGroups() + if (Array.isArray(groups)) existingGroups = groups as typeof existingGroups + } catch { + /* ignore */ + } + + const existingGroupNums = new Set(existingGroups.map(g => g.number)) + const existingGroupNames = new Set(existingGroups.map(g => g.name.toLowerCase())) + const groupsToCreate = uniqueGroups.filter( + g => !existingGroupNums.has(g.number) && !existingGroupNames.has(g.name.toLowerCase()), + ) + + console.log('\nšŸ·ļø Groups:') + console.log(` āœ“ Existing: ${uniqueGroups.length - groupsToCreate.length}`) + if (groupsToCreate.length > 0) { + console.log(` āž• To create: ${groupsToCreate.length}`) + for (const g of groupsToCreate) { + console.log(` - ${g.name} (${g.number})`) + } + } + + const proceed = await confirm({ + message: '\nProceed with import?', + default: false, + }) + + if (!proceed) { + console.log('\nāŒ Cancelled') + return { success: false, message: 'Cancelled' } + } + + console.log('\nšŸš€ Importing...') + + const result = await executeCompleteImport(exportedAttrs, existingAttrs, true) + + // Summary + console.log('\nšŸ“Š Results:') + if (result.success) { + console.log(' āœ… Success') + if (result.createdGroups.length) console.log(` šŸ·ļø Groups: ${result.createdGroups.length}`) + if (result.createdAttributes.length) console.log(` āž• Created: ${result.createdAttributes.length}`) + if (result.preservedAttributes.length) console.log(` šŸ“‹ Preserved: ${result.preservedAttributes.length}`) + } else { + console.log(' āŒ Failed') + for (const e of result.errors) { + console.log(` āœ— ${e}`) + } + } + + return result + } catch (error) { + console.error('\nšŸ’„ Error:', error) + return { success: false, error: String(error) } + } +} diff --git a/src/scripts/commercetools/export-custom-types.ts b/src/scripts/commercetools/export-custom-types.ts new file mode 100644 index 0000000..52a0b72 --- /dev/null +++ b/src/scripts/commercetools/export-custom-types.ts @@ -0,0 +1,26 @@ +import { getAllCustomTypes } from '../../utils/commercetools/get-all-custom-types' + +export default async () => { + try { + console.log('\nšŸ”· CommerceTools Custom Types Export') + console.log('=====================================\n') + + const types = await getAllCustomTypes() + + console.log(`āœ… Exported ${types.length} custom types`) + + if (types.length > 0) { + for (const t of types.slice(0, 10)) { + console.log(` āœ“ ${t.name?.en || t.key} (${t.fieldDefinitions?.length || 0} fields)`) + } + if (types.length > 10) console.log(` ... and ${types.length - 10} more`) + } + + console.log('\nšŸ’¾ Saved to: outputs/commercetools/export-custom-types.json') + + return types + } catch (error) { + console.error('\nšŸ’„ Export failed:', error) + return { success: false, error: String(error), data: [] } + } +} diff --git a/src/scripts/commercetools/import-custom-types.ts b/src/scripts/commercetools/import-custom-types.ts new file mode 100644 index 0000000..e6bf4bc --- /dev/null +++ b/src/scripts/commercetools/import-custom-types.ts @@ -0,0 +1,140 @@ +import { resolve } from 'node:path' +import type { Type } from '@commercetools/platform-sdk' +import { confirm, select } from '@inquirer/prompts' +import { commercetoolsClient } from '../../clients/commercetools' +import { environment } from '../../environment' +import { getAllCustomTypes } from '../../utils/commercetools/get-all-custom-types' + +export default async () => { + try { + console.log('\nšŸ”· CommerceTools Custom Types Import') + console.log('=====================================\n') + + const updateBUType = await select({ + message: 'Update existing Business Unit custom types?', + choices: [ + { name: 'Yes - Include business-unit-type', value: true }, + { name: 'No - Protect business-unit-type', value: false }, + ], + }) + + console.log('šŸ“ Loading data...') + + const fullPath = resolve(__dirname, '../../outputs/commercetools/export-custom-types.json') + const importedData = (await import(fullPath)) as unknown as { + default: Type[] + } + const exportedTypes = importedData.default || (importedData as unknown as Type[]) + const existingTypes = (await getAllCustomTypes()) ?? [] + + console.log(`āœ“ ${exportedTypes.length} exported, ${existingTypes.length} existing\n`) + + // Calculate what needs to be done + const shouldProcess = (key: string) => updateBUType || key !== 'business-unit-type' + + const toUpdate = exportedTypes.filter(ct => existingTypes.some(e => e.key === ct.key) && shouldProcess(ct.key)) + + const toCreate = exportedTypes.filter(ct => !existingTypes.some(e => e.key === ct.key) && shouldProcess(ct.key)) + + const extraTypes = existingTypes.filter(ct => !exportedTypes.some(e => e.key === ct.key) && shouldProcess(ct.key)) + + // Preview + console.log('šŸ“‹ Preview:') + console.log(` šŸ”„ To update: ${toUpdate.length}`) + console.log(` āž• To create: ${toCreate.length}`) + console.log(` šŸ“‹ Extra (preserved): ${extraTypes.length}`) + if (!updateBUType) console.log(' šŸ›”ļø Protected: business-unit-type') + + if (toUpdate.length > 0) { + console.log('\nšŸ”„ Will update (add missing fields):') + for (const ct of toUpdate.slice(0, 5)) { + console.log(` - ${ct.name.en ?? ct.key} (${ct.fieldDefinitions?.length || 0} fields)`) + } + if (toUpdate.length > 5) console.log(` ... and ${toUpdate.length - 5} more`) + } + + if (toCreate.length > 0) { + console.log('\n✨ Will create:') + for (const ct of toCreate.slice(0, 5)) { + console.log(` - ${ct.name.en ?? ct.key}`) + } + if (toCreate.length > 5) console.log(` ... and ${toCreate.length - 5} more`) + } + + const proceed = await confirm({ + message: `\nProceed with import to "${environment.commercetools.projectKey}"?`, + default: false, + }) + + if (!proceed) { + console.log('\nāŒ Cancelled') + return { success: false, message: 'Cancelled' } + } + + console.log('\nšŸš€ Importing...') + + const results = { + updated: [] as string[], + created: [] as string[], + errors: [] as string[], + } + + // Phase 1: Update existing (add missing fields) + for (const exportedType of toUpdate) { + const existing = existingTypes.find(e => e.key === exportedType.key) + if (!existing) continue + + const existingFieldNames = existing.fieldDefinitions.map(f => f.name) + const missingFields = exportedType.fieldDefinitions.filter(f => !existingFieldNames.includes(f.name)) + + if (missingFields.length === 0) continue + + try { + let currentVersion = (await commercetoolsClient.types().withKey({ key: exportedType.key }).get().execute()).body + .version + + for (const field of missingFields) { + const response = await commercetoolsClient + .types() + .withKey({ key: exportedType.key }) + .post({ + body: { + version: currentVersion, + actions: [{ action: 'addFieldDefinition', fieldDefinition: field }], + }, + }) + .execute() + currentVersion = response.body.version + } + + results.updated.push(exportedType.key) + console.log(` āœ“ Updated ${exportedType.key} (+${missingFields.length} fields)`) + } catch (error) { + console.log(` āš ļø Failed to update ${exportedType.key}: ${error}`) + } + } + + // Phase 2: Create new types + for (const ct of toCreate) { + try { + await commercetoolsClient.types().post({ body: ct }).execute() + results.created.push(ct.key) + console.log(` āœ“ Created ${ct.key}`) + } catch (error) { + results.errors.push(`${ct.key}: ${error}`) + console.log(` āš ļø Failed to create ${ct.key}: ${error}`) + } + } + + // Summary + console.log('\nšŸ“Š Results:') + console.log(` āœ“ Updated: ${results.updated.length}`) + console.log(` āœ“ Created: ${results.created.length}`) + if (results.errors.length) console.log(` āœ— Errors: ${results.errors.length}`) + + return { success: results.errors.length === 0, ...results } + } catch (error) { + console.error('\nšŸ’„ Error:', error) + return { success: false, error: String(error) } + } +} diff --git a/src/types/bluestone/bluestone.types.ts b/src/types/bluestone/bluestone.types.ts new file mode 100644 index 0000000..b250a00 --- /dev/null +++ b/src/types/bluestone/bluestone.types.ts @@ -0,0 +1,269 @@ +/** + * Bluestone API Types + * + * Type definitions for Bluestone attribute responses and related data structures. + * Generated from export-attributes.json structure analysis. + */ + +/** + * Represents a single attribute value option for select-type attributes + */ +export interface BluestoneAttributeValue { + /** Unique identifier for this attribute value */ + valueId: string + /** Number/code identifier for this value */ + number: string + /** Human-readable display value */ + value: string +} + +/** + * Supported data types for Bluestone attributes + */ +export type BluestoneAttributeDataType = + | 'text' + | 'multiline' + | 'single_select' + | 'multi_select' + | 'boolean' + | 'integer' + | 'decimal' + +/** + * Represents a single Bluestone attribute definition + */ +export interface BluestoneAttribute { + /** Display order within the group */ + order: number + /** Order of the group this attribute belongs to */ + groupOrder: number + /** Unique identifier of the attribute group */ + groupId: string + /** Human-readable name of the attribute group */ + groupName: string + /** Code/number identifier for the group */ + groupNumber: string + /** Sorting order for this attribute */ + sortingOrder: number + /** Unique identifier for this attribute */ + id: string + /** Human-readable name of the attribute */ + name: string + /** Code/number identifier for this attribute */ + number: string + /** Optional description explaining the attribute's purpose */ + description?: string + /** Data type that determines how values are stored and validated */ + dataType: BluestoneAttributeDataType + /** Available values for select-type attributes (empty for other types) */ + values: BluestoneAttributeValue[] + /** Whether this attribute supports compound/complex values */ + isCompound: boolean + /** Whether this attribute is context-aware (varies by context/locale) */ + contextAware: boolean +} + +/** + * Response type for Bluestone attributes export/list operations + * Array of attribute definitions + */ +export type BluestoneAttributesResponse = BluestoneAttribute[] + +/** + * Grouped attributes by their group information + * Useful for organizing attributes by their logical groupings + */ +export interface BluestoneAttributeGroup { + /** Group metadata */ + groupId: string + groupName: string + groupNumber: string + groupOrder: number + /** Attributes belonging to this group */ + attributes: BluestoneAttribute[] +} + +/** + * Utility type for organizing attributes by groups + */ +export type BluestoneAttributesByGroup = Record + +/** + * Filter options for querying Bluestone attributes + */ +export interface BluestoneAttributeFilter { + /** Filter by specific group IDs */ + groupIds?: string[] + /** Filter by data types */ + dataTypes?: BluestoneAttributeDataType[] + /** Filter by context awareness */ + contextAware?: boolean + /** Filter by compound support */ + isCompound?: boolean + /** Search by attribute name or description */ + search?: string +} + +/** + * Options for attribute value operations + */ +export interface BluestoneAttributeValueOptions { + /** The attribute ID to work with */ + attributeId: string + /** Values to add/update/remove */ + values: Partial[] +} + +/** + * Request payload for creating/updating Bluestone attributes + */ +export interface BluestoneAttributeRequest { + /** Attribute name */ + name: string + /** Attribute number/code */ + number: string + /** Optional description */ + description?: string + /** Data type */ + dataType: BluestoneAttributeDataType + /** Group ID where this attribute belongs */ + groupId: string + /** Sorting order */ + sortingOrder?: number + /** Whether attribute is compound */ + isCompound?: boolean + /** Whether attribute is context-aware */ + contextAware?: boolean + /** Initial values for select-type attributes */ + values?: Omit[] +} + +/** + * Response type for attribute operations (create/update/delete) + */ +export interface BluestoneAttributeOperationResponse { + /** Whether the operation was successful */ + success: boolean + /** The affected attribute (for create/update operations) */ + attribute?: BluestoneAttribute + /** Error message if operation failed */ + error?: string + /** Additional operation details */ + details?: string +} + +/** + * Request payload for creating Bluestone attribute groups + */ +export interface BluestoneAttributeGroupRequest { + /** Group name */ + name: string + /** Group number/code identifier */ + number: string + /** Optional description */ + description?: string + /** Optional sorting order */ + sortingOrder?: number +} + +/** + * Request payload for creating Bluestone attributes via Management API + * Based on official API documentation: https://docs.api.bluestonepim.com/reference/createattributedefinition + */ +export interface BluestoneAttributeCreateRequest { + /** Attribute name (required) */ + name: string + /** Attribute number/code */ + number?: string + /** Data type (required) - must be one of the valid enum values */ + dataType: + | 'boolean' + | 'integer' + | 'decimal' + | 'date' + | 'time' + | 'date_time' + | 'location' + | 'single_select' + | 'multi_select' + | 'text' + | 'formatted_text' + | 'pattern' + | 'multiline' + | 'column' + | 'matrix' + | 'dictionary' + /** Optional description */ + description?: string + /** Whether attribute is context aware */ + contextAware?: boolean + /** Character set used for the attribute definition */ + charset?: string + /** Content type associated with the attribute definition */ + contentType?: string + /** External source flag */ + externalSource?: boolean + /** Group ID for the attribute */ + groupId?: string + /** Internal flag */ + internal?: boolean + /** Restrictions associated with the attribute definition */ + restrictions?: { + /** For select-type attributes (single_select, multi_select) - correct format */ + enum?: { + values: Array<{ + value: string + }> + } + } + /** Unit of measurement for the attribute definition */ + unit?: string +} + +/** + * Standard API response wrapper for Bluestone operations + */ +export interface BluestoneApiResponse { + /** Whether the operation was successful */ + success: boolean + /** Resource ID returned in response header (for create operations) */ + resourceId?: string | null + /** Response data */ + data?: Record + /** Error message if operation failed */ + error?: string +} + +/** + * Import operation plan for orchestrating the import process + */ +export interface BluestoneImportPlan { + /** Groups that need to be created */ + groupsToCreate: BluestoneAttributeGroupRequest[] + /** Attributes that should be kept (already exist and match) */ + attributesToKeep: BluestoneAttribute[] + /** Attributes that need to be deleted */ + attributesToDelete: BluestoneAttribute[] + /** Attributes that need to be created */ + attributesToCreate: BluestoneAttributeCreateRequest[] + /** Mapping of group numbers to their IDs (for assignment) */ + groupMapping: Record +} + +/** + * Result of import operations + */ +export interface BluestoneImportResult { + /** Whether the overall import was successful */ + success: boolean + /** Created groups with their IDs */ + createdGroups: Array<{ groupNumber: string; groupId: string }> + /** Created attributes with their IDs */ + createdAttributes: Array<{ attributeNumber: string; attributeId: string }> + /** Deleted attributes */ + preservedAttributes: Array<{ attributeNumber: string; attributeId: string }> + /** Assignment results */ + assignmentResults: Array<{ groupId: string; attributeIds: string[] }> + /** Any errors that occurred */ + errors: string[] +} diff --git a/src/types/bluestone/index.ts b/src/types/bluestone/index.ts new file mode 100644 index 0000000..b213371 --- /dev/null +++ b/src/types/bluestone/index.ts @@ -0,0 +1,7 @@ +/** + * Bluestone Types Export + * + * Centralized export for all Bluestone-related type definitions + */ + +export * from './bluestone.types' diff --git a/src/utils/bluestone/attribute-mapper.ts b/src/utils/bluestone/attribute-mapper.ts new file mode 100644 index 0000000..e64eb2c --- /dev/null +++ b/src/utils/bluestone/attribute-mapper.ts @@ -0,0 +1,129 @@ +import type { + BluestoneAttribute, + BluestoneAttributeCreateRequest, + BluestoneAttributeDataType, + BluestoneAttributeGroupRequest, +} from '../../types/bluestone/bluestone.types' + +type ApiDataType = + | 'boolean' + | 'integer' + | 'decimal' + | 'date' + | 'time' + | 'date_time' + | 'location' + | 'single_select' + | 'multi_select' + | 'text' + | 'formatted_text' + | 'pattern' + | 'multiline' + | 'column' + | 'matrix' + | 'dictionary' + +const mapDataType = (dataType: BluestoneAttributeDataType): ApiDataType => { + const mapping: Record = { + text: 'text', + multiline: 'multiline', + single_select: 'single_select', + multi_select: 'multi_select', + boolean: 'boolean', + integer: 'integer', + decimal: 'decimal', + } + return mapping[dataType] || 'text' +} + +export const mapJsonToBluestoneAttribute = ( + attr: BluestoneAttribute, + groupId?: string, +): BluestoneAttributeCreateRequest => { + const request: BluestoneAttributeCreateRequest = { + name: attr.name, + dataType: mapDataType(attr.dataType), + } + + if (attr.number) request.number = attr.number + if (attr.description) request.description = attr.description + if (attr.contextAware !== undefined) request.contextAware = attr.contextAware + if (groupId) request.groupId = groupId + + // Handle select types with enum values + if ((attr.dataType === 'single_select' || attr.dataType === 'multi_select') && attr.values?.length) { + request.dataType = attr.dataType + request.restrictions = { + enum: { values: attr.values.map(v => ({ value: v.value })) }, + } + } + + return request +} + +export const mapJsonToBluestoneGroup = (group: { + groupName: string + groupNumber: string + groupOrder?: number +}): BluestoneAttributeGroupRequest => ({ + name: group.groupName, + number: group.groupNumber, + sortingOrder: group.groupOrder, +}) + +export const validateAttributeData = ( + attr: BluestoneAttributeCreateRequest, +): { isValid: boolean; errors: string[] } => { + const errors: string[] = [] + + if (!attr.name?.trim()) errors.push('Name required') + if (!attr.number?.trim()) errors.push('Number required') + if (!attr.dataType?.trim()) errors.push('DataType required') + if (attr.name && attr.name.length > 255) errors.push('Name too long') + if (attr.number && !/^[a-zA-Z0-9_-]+$/.test(attr.number)) errors.push('Invalid number format') + + return { isValid: errors.length === 0, errors } +} + +export const validateGroupData = (group: BluestoneAttributeGroupRequest): { isValid: boolean; errors: string[] } => { + const errors: string[] = [] + + if (!group.name?.trim()) errors.push('Name required') + if (!group.number?.trim()) errors.push('Number required') + if (group.name && group.name.length > 255) errors.push('Name too long') + if (group.number && !/^[a-zA-Z0-9_-]+$/.test(group.number)) errors.push('Invalid number format') + + return { isValid: errors.length === 0, errors } +} + +export const validateAttributesBatch = (attrs: BluestoneAttributeCreateRequest[]) => { + const results = attrs.map(attr => ({ + attribute: attr, + ...validateAttributeData(attr), + })) + const totalErrors = results.reduce((sum, r) => sum + r.errors.length, 0) + + return { isValid: totalErrors === 0, totalErrors, attributeResults: results } +} + +export const prepareAttributesForCreation = (attrs: BluestoneAttribute[]) => { + const validAttributes: BluestoneAttributeCreateRequest[] = [] + const invalidAttributes: Array<{ + original: BluestoneAttribute + transformed: BluestoneAttributeCreateRequest + errors: string[] + }> = [] + + for (const attr of attrs) { + const transformed = mapJsonToBluestoneAttribute(attr) + const { isValid, errors } = validateAttributeData(transformed) + + if (isValid) { + validAttributes.push(transformed) + } else { + invalidAttributes.push({ original: attr, transformed, errors }) + } + } + + return { validAttributes, invalidAttributes } +} diff --git a/src/utils/bluestone/group-manager.ts b/src/utils/bluestone/group-manager.ts new file mode 100644 index 0000000..b017782 --- /dev/null +++ b/src/utils/bluestone/group-manager.ts @@ -0,0 +1,132 @@ +import { bluestoneClient } from '../../clients/bluestone' +import type { BluestoneAttribute } from '../../types/bluestone/bluestone.types' + +export interface AttributeGroupInfo { + groupId: string + groupName: string + groupNumber: string + groupOrder: number + attributes: BluestoneAttribute[] +} + +type ExistingGroup = { + id: string + name: string + number: string + orderByName?: boolean +} + +const parseGroupsResponse = (response: unknown): ExistingGroup[] => { + if (Array.isArray(response)) return response + if (response && typeof response === 'object') { + const data = (response as { data?: unknown[] }).data + if (Array.isArray(data)) return data as ExistingGroup[] + } + return [] +} + +export const ensureAttributeGroupExists = async (groupData: { + groupName: string + groupNumber: string + groupOrder?: number +}): Promise => { + try { + const existingGroups = parseGroupsResponse(await bluestoneClient.getAttributeGroups()) + const existing = existingGroups.find(g => g.number === groupData.groupNumber) + + if (existing) { + console.log(` āœ“ Found: ${groupData.groupName}`) + return existing.id + } + + console.log(` āž• Creating: ${groupData.groupName}`) + const response = await bluestoneClient.createAttributeGroup({ + name: groupData.groupName, + number: groupData.groupNumber, + sortingOrder: groupData.groupOrder, + }) + + if (!response.success || !response.resourceId) { + throw new Error(`No resource ID returned for ${groupData.groupName}`) + } + + return response.resourceId + } catch (error) { + // Handle 409 Conflict - group already exists + if (error instanceof Error && error.message.includes('409 Conflict')) { + const match = error.message.match(/"entityId":"([^"]+)"/) + if (match?.[1]) return match[1] + + // Fallback: fetch again + const groups = parseGroupsResponse(await bluestoneClient.getAttributeGroups()) + const found = groups.find(g => g.name === groupData.groupName || g.number === groupData.groupNumber) + if (found) return found.id + } + throw error + } +} + +export const getOrCreateAttributeGroup = (groupName: string, groupNumber: string) => + ensureAttributeGroupExists({ groupName, groupNumber }) + +export const organizeAttributesByGroup = (attributes: BluestoneAttribute[]): Record => { + const map: Record = {} + + for (const attr of attributes) { + if (!map[attr.groupNumber]) { + map[attr.groupNumber] = { + groupId: attr.groupId, + groupName: attr.groupName, + groupNumber: attr.groupNumber, + groupOrder: attr.groupOrder, + attributes: [], + } + } + map[attr.groupNumber].attributes.push(attr) + } + + return map +} + +export const extractUniqueGroups = (attributes: BluestoneAttribute[]) => { + const map = new Map< + string, + { + groupId: string + groupName: string + groupNumber: string + groupOrder: number + } + >() + + for (const attr of attributes) { + if (!map.has(attr.groupNumber)) { + map.set(attr.groupNumber, { + groupId: attr.groupId, + groupName: attr.groupName, + groupNumber: attr.groupNumber, + groupOrder: attr.groupOrder, + }) + } + } + + return Array.from(map.values()) +} + +export const createGroupsFromAttributes = async (attributes: BluestoneAttribute[]): Promise> => { + const uniqueGroups = extractUniqueGroups(attributes) + const mapping: Record = {} + + console.log(` Processing ${uniqueGroups.length} groups...`) + + for (const group of uniqueGroups) { + const groupId = await ensureAttributeGroupExists({ + groupName: group.groupName, + groupNumber: group.groupNumber, + groupOrder: group.groupOrder, + }) + mapping[group.groupNumber] = groupId + } + + return mapping +} diff --git a/src/utils/bluestone/import-orchestrator.ts b/src/utils/bluestone/import-orchestrator.ts new file mode 100644 index 0000000..1cd381b --- /dev/null +++ b/src/utils/bluestone/import-orchestrator.ts @@ -0,0 +1,153 @@ +import { bluestoneClient } from '../../clients/bluestone' +import type { + BluestoneAttribute, + BluestoneAttributeGroupRequest, + BluestoneImportPlan, + BluestoneImportResult, +} from '../../types/bluestone/bluestone.types' +import { mapJsonToBluestoneAttribute, prepareAttributesForCreation } from './attribute-mapper' +import { createGroupsFromAttributes, extractUniqueGroups } from './group-manager' + +const attributeExists = (exported: BluestoneAttribute, existingAttrs: BluestoneAttribute[]) => + existingAttrs.some(e => e.number === exported.number || e.name.toLowerCase() === exported.name.toLowerCase()) + +export const planImportOperations = async ( + exportedAttrs: BluestoneAttribute[], + existingAttrs: BluestoneAttribute[], + keepExisting: boolean, +): Promise => { + const uniqueGroups = extractUniqueGroups(exportedAttrs) + + // Fetch existing groups + let existingGroups: Array<{ id: string; name: string; number: string }> = [] + try { + const response = await bluestoneClient.getAttributeGroups() + if (Array.isArray(response)) existingGroups = response + } catch { + /* ignore */ + } + + const existingGroupNums = new Set(existingGroups.map(g => g.number)) + const existingGroupNames = new Set(existingGroups.map(g => g.name.toLowerCase())) + + const groupsToCreate: BluestoneAttributeGroupRequest[] = uniqueGroups + .filter(g => !existingGroupNums.has(g.groupNumber) && !existingGroupNames.has(g.groupName.toLowerCase())) + .map(g => ({ + name: g.groupName, + number: g.groupNumber, + sortingOrder: g.groupOrder, + })) + + const toKeep = existingAttrs.filter(e => + exportedAttrs.some(exp => e.number === exp.number || e.name.toLowerCase() === exp.name.toLowerCase()), + ) + + const toPreserve = existingAttrs.filter( + e => !exportedAttrs.some(exp => e.number === exp.number || e.name.toLowerCase() === exp.name.toLowerCase()), + ) + + const toCreateAttrs = exportedAttrs + .filter(exp => !attributeExists(exp, existingAttrs)) + .map(attr => mapJsonToBluestoneAttribute(attr)) + + return { + groupsToCreate, + attributesToKeep: keepExisting ? toKeep : [...existingAttrs], + attributesToDelete: toPreserve, + attributesToCreate: toCreateAttrs, + groupMapping: {}, + } +} + +export const executeImportPlan = async ( + plan: BluestoneImportPlan, + exportedAttrs: BluestoneAttribute[], +): Promise => { + const result: BluestoneImportResult = { + success: false, + createdGroups: [], + createdAttributes: [], + preservedAttributes: [], + assignmentResults: [], + errors: [], + } + + try { + // Phase 1: Ensure groups exist + console.log('\nšŸ“ Processing groups...') + const groupMapping = await createGroupsFromAttributes(exportedAttrs) + plan.groupMapping = groupMapping + + for (const [groupNumber, groupId] of Object.entries(groupMapping)) { + result.createdGroups.push({ groupNumber, groupId }) + } + + // Track preserved attributes + for (const attr of plan.attributesToDelete) { + result.preservedAttributes.push({ + attributeNumber: attr.number, + attributeId: attr.id, + }) + } + + // Phase 2: Create new attributes + if (plan.attributesToCreate.length > 0) { + console.log(`\nšŸ“ Creating ${plan.attributesToCreate.length} attributes...`) + + const { validAttributes, invalidAttributes } = prepareAttributesForCreation( + exportedAttrs.filter(exp => plan.attributesToCreate.some(c => c.number === exp.number)), + ) + + if (invalidAttributes.length > 0) { + const msg = `${invalidAttributes.length} invalid attributes` + for (const inv of invalidAttributes) { + console.log(` āœ— ${inv.original.name}: ${inv.errors.join(', ')}`) + } + result.errors.push(msg) + throw new Error(msg) + } + + for (const attr of validAttributes) { + try { + const original = exportedAttrs.find(e => e.number === attr.number) + if (original?.groupNumber && groupMapping[original.groupNumber]) { + attr.groupId = groupMapping[original.groupNumber] + } + + const response = await bluestoneClient.createAttribute(attr) + if (response.success && response.resourceId) { + result.createdAttributes.push({ + attributeNumber: attr.number || '', + attributeId: response.resourceId, + }) + console.log(` āœ“ ${attr.name}`) + } else { + throw new Error('No resource ID') + } + } catch (error) { + const msg = `Failed: ${attr.name} - ${error}` + result.errors.push(msg) + throw error + } + } + } + + result.success = result.errors.length === 0 + return result + } catch (error) { + result.success = false + if (!result.errors.includes(String(error))) { + result.errors.push(String(error)) + } + return result + } +} + +export const executeCompleteImport = async ( + exportedAttrs: BluestoneAttribute[], + existingAttrs: BluestoneAttribute[], + keepExisting: boolean, +): Promise => { + const plan = await planImportOperations(exportedAttrs, existingAttrs, keepExisting) + return executeImportPlan(plan, exportedAttrs) +} diff --git a/src/utils/commercetools/get-all-custom-types.ts b/src/utils/commercetools/get-all-custom-types.ts new file mode 100644 index 0000000..1a9daf8 --- /dev/null +++ b/src/utils/commercetools/get-all-custom-types.ts @@ -0,0 +1,35 @@ +import type { Type } from '@commercetools/platform-sdk' +import { commercetoolsClient } from '../../clients/commercetools' + +export const getAllCustomTypes = async (): Promise => { + try { + let offset = 0 + const limit = 20 + const customTypes: Type[] = [] + while (true) { + const customTypesResponse = await commercetoolsClient + .types() + .get({ + queryArgs: { + limit, + offset, + withTotal: true, + }, + }) + .execute() + customTypes.push(...customTypesResponse.body.results) + offset += limit + if (offset >= (customTypesResponse.body.total ?? 0)) break + } + + if (!customTypes.length) { + console.log('No custom types found') + return [] + } + + return customTypes + } catch (error) { + console.error(JSON.stringify(error)) + return [] + } +} From a61811f5fb0aabd20323c8bcc15fb8e705c22227 Mon Sep 17 00:00:00 2001 From: Jorge Date: Wed, 17 Dec 2025 11:56:58 -0500 Subject: [PATCH 2/2] refactor: improve pagination loop readability --- src/clients/bluestone.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/clients/bluestone.ts b/src/clients/bluestone.ts index 2af3aee..6e69b25 100644 --- a/src/clients/bluestone.ts +++ b/src/clients/bluestone.ts @@ -78,10 +78,12 @@ const getAttributeGroups = async () => { const groups: Array<{ id: string; name: string; number: string }> = [] const pageSize = 1000 let page = 0 + let hasMore = true - do { + while (hasMore) { const headers = await getAuthHeaders() - const response = await fetch(`${environment.bluestone.attributeGroupsUrl}?page=${page}&pageSize=${pageSize}`, { + const url = `${environment.bluestone.attributeGroupsUrl}?page=${page}&pageSize=${pageSize}` + const response = await fetch(url, { method: 'GET', headers: { ...headers, context: environment.bluestone.context }, }) @@ -97,10 +99,8 @@ const getAttributeGroups = async () => { groups.push(...pageGroups) page++ - // If we got less than pageSize, we've reached the end - if (pageGroups.length < pageSize) break - // biome-ignore lint/correctness/noConstantCondition: pagination loop - } while (true) + hasMore = pageGroups.length >= pageSize + } return groups }