/**
* Component Operations Module
* Handles all AEM component-related operations including CRUD, validation, and bulk updates
*/
import { AxiosInstance } from 'axios';
import {
IAEMConnector,
CreateComponentRequest,
UpdateComponentRequest,
DeleteComponentRequest,
ValidateComponentRequest,
BulkUpdateComponentsRequest,
ComponentResponse,
UpdateResponse,
DeleteResponse,
ValidateResponse,
ScanComponentsResponse,
BulkUpdateResponse,
ILogger,
AEMConfig
} from '../interfaces/index.js';
import {
AEMOperationError,
createAEMError,
handleAEMHttpError,
safeExecute,
createSuccessResponse,
AEM_ERROR_CODES,
isValidContentPath,
isValidComponentType,
validateComponentOperation
} from '../error-handler.js';
export class ComponentOperations implements Partial<IAEMConnector> {
constructor(
private httpClient: AxiosInstance,
private logger: ILogger,
private config: AEMConfig
) {}
/**
* Create a new component on a page
*/
async createComponent(request: CreateComponentRequest): Promise<ComponentResponse> {
return safeExecute<ComponentResponse>(async () => {
const { pagePath, componentType, resourceType, properties = {}, name } = request;
if (!isValidContentPath(pagePath)) {
throw createAEMError(
AEM_ERROR_CODES.INVALID_PARAMETERS,
`Invalid page path: ${String(pagePath)}`,
{ pagePath }
);
}
if (!isValidComponentType(componentType)) {
throw createAEMError(
AEM_ERROR_CODES.INVALID_PARAMETERS,
`Invalid component type: ${componentType}`,
{ componentType }
);
}
const componentName = name || `${componentType}_${Date.now()}`;
const componentPath = `${pagePath}/jcr:content/${componentName}`;
await this.httpClient.post(componentPath, {
'jcr:primaryType': 'nt:unstructured',
'sling:resourceType': resourceType,
...properties,
':operation': 'import',
':contentType': 'json',
':replace': 'true',
});
return createSuccessResponse({
success: true,
componentPath,
componentType,
resourceType,
properties,
timestamp: new Date().toISOString(),
}, 'createComponent') as ComponentResponse;
}, 'createComponent');
}
/**
* Update component properties in AEM
*/
async updateComponent(request: UpdateComponentRequest): Promise<UpdateResponse> {
return safeExecute<UpdateResponse>(async () => {
const { componentPath, properties } = request;
if (!componentPath || typeof componentPath !== 'string') {
throw createAEMError(
AEM_ERROR_CODES.INVALID_PARAMETERS,
'Component path is required and must be a string'
);
}
if (!properties || typeof properties !== 'object') {
throw createAEMError(
AEM_ERROR_CODES.INVALID_PARAMETERS,
'Properties are required and must be an object'
);
}
if (!isValidContentPath(componentPath)) {
throw createAEMError(
AEM_ERROR_CODES.INVALID_PATH,
`Component path '${componentPath}' is not within allowed content roots`,
{
path: componentPath,
allowedRoots: Object.values(this.config.contentPaths)
}
);
}
// Verify component exists
try {
await this.httpClient.get(`${componentPath}.json`);
} catch (error: any) {
if (error.response?.status === 404) {
throw createAEMError(
AEM_ERROR_CODES.COMPONENT_NOT_FOUND,
`Component not found at path: ${componentPath}`,
{ componentPath }
);
}
throw handleAEMHttpError(error, 'updateComponent');
}
// Prepare form data for AEM
const formData = new URLSearchParams();
Object.entries(properties).forEach(([key, value]) => {
if (value === null || value === undefined) {
formData.append(`${key}@Delete`, '');
} else if (Array.isArray(value)) {
value.forEach((item) => {
formData.append(`${key}`, item.toString());
});
} else if (typeof value === 'object') {
formData.append(key, JSON.stringify(value));
} else {
formData.append(key, value.toString());
}
});
// Update the component
const response = await this.httpClient.post(componentPath, formData, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
timeout: this.config.queries.timeoutMs,
});
// Verify the update
const verificationResponse = await this.httpClient.get(`${componentPath}.json`);
return createSuccessResponse({
message: 'Component updated successfully',
path: componentPath,
properties,
updatedProperties: verificationResponse.data,
response: response.data,
verification: {
success: true,
propertiesChanged: Object.keys(properties).length,
timestamp: new Date().toISOString(),
},
}, 'updateComponent') as UpdateResponse;
}, 'updateComponent');
}
/**
* Delete a component from AEM
*/
async deleteComponent(request: DeleteComponentRequest): Promise<DeleteResponse> {
return safeExecute<DeleteResponse>(async () => {
const { componentPath, force = false } = request;
if (!isValidContentPath(componentPath)) {
throw createAEMError(
AEM_ERROR_CODES.INVALID_PARAMETERS,
`Invalid component path: ${String(componentPath)}`,
{ componentPath }
);
}
let deleted = false;
try {
await this.httpClient.delete(componentPath);
deleted = true;
} catch (err: any) {
if (err.response && err.response.status === 405) {
try {
await this.httpClient.post(componentPath, { ':operation': 'delete' });
deleted = true;
} catch (slingErr: any) {
this.logger.error('Sling POST delete failed', {
error: slingErr.response?.status,
data: slingErr.response?.data
});
throw slingErr;
}
} else {
this.logger.error('DELETE failed', {
status: err.response?.status,
data: err.response?.data
});
throw err;
}
}
return createSuccessResponse({
success: deleted,
deletedPath: componentPath,
timestamp: new Date().toISOString(),
}, 'deleteComponent') as DeleteResponse;
}, 'deleteComponent');
}
/**
* Validate component changes before applying them
*/
async validateComponent(request: ValidateComponentRequest): Promise<ValidateResponse> {
return safeExecute<ValidateResponse>(async () => {
const { locale, pagePath, component, props } = request;
validateComponentOperation(locale, pagePath, component, props);
// Validate locale
const normalizedLocale = locale.toLowerCase();
const isValidLocale = this.config.validation.allowedLocales.some(
l => l.toLowerCase() === normalizedLocale ||
(normalizedLocale === 'en' && l.toLowerCase().startsWith('en'))
);
if (!isValidLocale) {
throw createAEMError(
AEM_ERROR_CODES.INVALID_LOCALE,
`Locale '${locale}' is not supported`,
{
locale,
allowedLocales: this.config.validation.allowedLocales
}
);
}
// Validate content path
if (!isValidContentPath(pagePath)) {
throw createAEMError(
AEM_ERROR_CODES.INVALID_PATH,
`Path '${pagePath}' is not within allowed content roots`,
{
path: pagePath,
allowedRoots: Object.values(this.config.contentPaths)
}
);
}
// Validate component type
if (!isValidComponentType(component)) {
throw createAEMError(
AEM_ERROR_CODES.INVALID_COMPONENT_TYPE,
`Component type '${component}' is not allowed`,
{
component,
allowedTypes: this.config.components.allowedTypes
}
);
}
// Get page data for validation
const response = await this.httpClient.get(`${pagePath}.json`, {
params: { ':depth': '2' },
timeout: this.config.queries.timeoutMs,
});
const validation = this.validateComponentProps(response.data, component, props);
return createSuccessResponse({
message: 'Component validation completed successfully',
pageData: response.data,
component,
locale,
validation,
configUsed: {
allowedLocales: this.config.validation.allowedLocales,
allowedComponents: this.config.components.allowedTypes,
},
}, 'validateComponent') as ValidateResponse;
}, 'validateComponent');
}
/**
* Scan a page to discover all components and their properties
*/
async scanPageComponents(pagePath: string): Promise<ScanComponentsResponse> {
return safeExecute<ScanComponentsResponse>(async () => {
const response = await this.httpClient.get(`${pagePath}.infinity.json`);
const components: Array<{
path: string;
resourceType: string;
properties: Record<string, unknown>;
}> = [];
const processNode = (node: any, nodePath: string) => {
if (!node || typeof node !== 'object') return;
if (node['sling:resourceType']) {
components.push({
path: nodePath,
resourceType: node['sling:resourceType'],
properties: { ...node },
});
}
Object.entries(node).forEach(([key, value]) => {
if (typeof value === 'object' && value !== null &&
!key.startsWith('rep:') && !key.startsWith('oak:')) {
const childPath = nodePath ? `${nodePath}/${key}` : key;
processNode(value, childPath);
}
});
};
if (response.data['jcr:content']) {
processNode(response.data['jcr:content'], 'jcr:content');
} else {
processNode(response.data, pagePath);
}
return createSuccessResponse({
pagePath,
components,
totalComponents: components.length,
}, 'scanPageComponents') as ScanComponentsResponse;
}, 'scanPageComponents');
}
/**
* Update multiple components in a single operation with validation and rollback support
*/
async bulkUpdateComponents(request: BulkUpdateComponentsRequest): Promise<BulkUpdateResponse> {
return safeExecute<BulkUpdateResponse>(async () => {
const { updates, validateFirst = true, continueOnError = false } = request;
if (!Array.isArray(updates) || updates.length === 0) {
throw createAEMError(
AEM_ERROR_CODES.INVALID_PARAMETERS,
'Updates array is required and cannot be empty'
);
}
const results: Array<{
componentPath: string;
success: boolean;
result?: unknown;
error?: string;
phase: string;
}> = [];
// Validation phase if requested
if (validateFirst) {
for (const update of updates) {
try {
await this.httpClient.get(`${update.componentPath}.json`);
} catch (error: any) {
if (error.response?.status === 404) {
results.push({
componentPath: update.componentPath,
success: false,
error: `Component not found: ${update.componentPath}`,
phase: 'validation'
});
if (!continueOnError) {
return createSuccessResponse({
success: false,
message: 'Bulk update failed during validation phase',
results,
totalUpdates: updates.length,
successfulUpdates: 0
}, 'bulkUpdateComponents') as BulkUpdateResponse;
}
}
}
}
}
// Update phase
let successCount = 0;
for (const update of updates) {
try {
const result = await this.updateComponent({
componentPath: update.componentPath,
properties: update.properties
});
results.push({
componentPath: update.componentPath,
success: true,
result: result,
phase: 'update'
});
successCount++;
} catch (error: any) {
results.push({
componentPath: update.componentPath,
success: false,
error: error.message,
phase: 'update'
});
if (!continueOnError) {
break;
}
}
}
return createSuccessResponse({
success: successCount === updates.length,
message: `Bulk update completed: ${successCount}/${updates.length} successful`,
results,
totalUpdates: updates.length,
successfulUpdates: successCount,
failedUpdates: updates.length - successCount
}, 'bulkUpdateComponents') as BulkUpdateResponse;
}, 'bulkUpdateComponents');
}
/**
* Update the image path for an image component and verify the update
*/
async updateImagePath(componentPath: string, newImagePath: string): Promise<UpdateResponse> {
return this.updateComponent({
componentPath,
properties: { fileReference: newImagePath }
});
}
/**
* Validate component properties based on component type
*/
private validateComponentProps(pageData: any, componentType: string, props: any): {
valid: boolean;
errors: string[];
warnings: string[];
componentType: string;
propsValidated: number;
} {
const warnings: string[] = [];
const errors: string[] = [];
if (componentType === 'text' && !props.text && !props.richText) {
warnings.push('Text component should have text or richText property');
}
if (componentType === 'image' && !props.fileReference && !props.src) {
errors.push('Image component requires fileReference or src property');
}
return {
valid: errors.length === 0,
errors,
warnings,
componentType,
propsValidated: Object.keys(props).length,
};
}
}