Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"test:m5-02": "node ../../tools/m5-02/run-focused-api-test.mjs",
"test:m5-03": "node ../../tools/m5-03/run-focused-api-test.mjs",
"test:m5-04": "node ../../tools/m5-04/run-focused-api-test.mjs",
"test:m5-05": "node ../../tools/m5-05/run-focused-api-test.mjs",
"test:m4-01": "npm run build && tsc -p tsconfig.test.json && node --test dist-test/test/m4-01-meta-app-config.spec.js",
"test:m3-09": "node ../../tools/m3-09/run-focused-test.mjs",
"test:m3-08": "node ../../tools/m3-08/run-focused-test.mjs",
Expand Down
118 changes: 118 additions & 0 deletions apps/api/src/automation-config/automation-config.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import {
BadRequestException,
Body,
Controller,
Get,
Headers,
HttpException,
HttpStatus,
Param,
Patch,
Req,
Res,
UseGuards,
} from '@nestjs/common';
import { IsInt, IsOptional, IsString, Matches, Max, MaxLength, Min } from 'class-validator';
import type { Response } from 'express';
import type { components } from '@ca-meta/contracts';
import { PublicRoute } from '../common/security/public-route.decorator.js';
import { PortalSessionGuard, type PortalRequest } from '../identity/portal-session.guard.js';
import { PORTAL_CONTEXT_COOKIE, readPortalCookie } from '../identity/portal-session.service.js';
import { AutomationConfigService, automationConfigEtag } from './automation-config.service.js';
import {
AutomationConfigNotFoundError,
AutomationConfigValidationError,
AutomationConfigVersionConflictError,
UnsupportedAutomationConfigFieldError,
} from './automation-config.types.js';

const CONTROL_SAFE_TEXT = /^[^\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]*$/;

class AutomationConfigPatchDto {
@IsInt()
@Min(0)
@Max(Number.MAX_SAFE_INTEGER)
version!: number;

@IsOptional()
@IsString()
@MaxLength(4096)
@Matches(CONTROL_SAFE_TEXT)
privateWelcomeMessage?: string;

@IsOptional()
@IsString()
@MaxLength(4096)
@Matches(CONTROL_SAFE_TEXT)
commentPublicReply?: string;

@IsOptional()
@IsString()
@MaxLength(4096)
@Matches(CONTROL_SAFE_TEXT)
commentPrivateReply?: string;
}

function contextRef(request: PortalRequest, headerContext?: string): string {
const ref = headerContext ?? readPortalCookie(request.headers.cookie, PORTAL_CONTEXT_COOKIE);
if (!ref || !/^[A-Za-z0-9._~-]{16,512}$/.test(ref)) throw new BadRequestException();
return ref;
}

function requestId(request: PortalRequest): string { return request.requestId ?? 'unknown'; }

function parseIfMatch(value: string | undefined): number {
const match = value ? /^"automation-v(0|[1-9]\d*)"$/.exec(value) : null;
const version = match ? Number(match[1]) : NaN;
if (!Number.isSafeInteger(version) || version < 0) throw new BadRequestException();
return version;
}

function mapAutomationError(error: unknown): never {
if (error instanceof AutomationConfigNotFoundError) throw new HttpException('Resource not available.', HttpStatus.NOT_FOUND);
if (error instanceof AutomationConfigVersionConflictError) throw new HttpException('Configuration changed. Reload before saving.', HttpStatus.PRECONDITION_FAILED);
if (error instanceof AutomationConfigValidationError || error instanceof UnsupportedAutomationConfigFieldError) {
throw new HttpException('Check the supported fields and message text.', HttpStatus.UNPROCESSABLE_ENTITY);
}
throw error;
}

@Controller()
export class AutomationConfigController {
constructor(private readonly config: AutomationConfigService) {}

@Get('assets/:assetId/automation-config/channels')
@PublicRoute()
@UseGuards(PortalSessionGuard)
async read(
@Param('assetId') assetId: string,
@Req() request: PortalRequest,
@Headers('x-tenant-context') headerContext?: string,
@Res({ passthrough: true }) response?: Response,
): Promise<components['schemas']['AutomationChannelConfig']> {
try {
const result = await this.config.read(request.portalSession, contextRef(request, headerContext), assetId, requestId(request));
response?.setHeader('ETag', automationConfigEtag(result.version));
return result;
} catch (error) { return mapAutomationError(error); }
}

@Patch('assets/:assetId/automation-config/channels')
@PublicRoute()
@UseGuards(PortalSessionGuard)
async update(
@Param('assetId') assetId: string,
@Body() body: AutomationConfigPatchDto,
@Req() request: PortalRequest,
@Headers('x-tenant-context') headerContext?: string,
@Headers('if-match') ifMatch?: string,
@Res({ passthrough: true }) response?: Response,
): Promise<components['schemas']['AutomationChannelConfig']> {
try {
const result = await this.config.update(request.portalSession, contextRef(request, headerContext), assetId,
requestId(request), parseIfMatch(ifMatch), body);
response?.setHeader('ETag', automationConfigEtag(result.version));
return result;
} catch (error) { return mapAutomationError(error); }
}
}
111 changes: 111 additions & 0 deletions apps/api/src/automation-config/automation-config.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import type { components } from '@ca-meta/contracts';
import { IdentityDeniedError } from '../identity/identity.errors.js';
import { isVerifiedPortalSession, type VerifiedPortalSession } from '../identity/identity.trust.js';
import { TenantAuthorizationService, TenantContextService } from '../identity/identity.service.js';
import type { MetaEnvironment } from '../meta-assets/meta-assets.types.js';
import type { MetaAssetsRepository } from '../meta-assets/mysql-meta-assets.repository.js';
import {
AutomationConfigNotFoundError,
AutomationConfigValidationError,
AutomationConfigVersionConflictError,
UnsupportedAutomationConfigFieldError,
automationConfigAssetKind,
automationConfigFieldsForAssetKind,
type AutomationConfigPatch,
type AutomationConfigRepository,
} from './automation-config.types.js';

const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const MAX_MESSAGE_LENGTH = 4096;
const DISALLOWED_CONTROLS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/;

function isEditor(roles: readonly string[]): boolean {
return roles.some((role) => role === 'owner' || role === 'admin' || role === 'operator');
}

function validatePatch(patch: AutomationConfigPatch, supportedFields: readonly string[]): void {
if (!Number.isSafeInteger(patch.version) || patch.version < 0) throw new AutomationConfigVersionConflictError();
const fields = ['privateWelcomeMessage', 'commentPublicReply', 'commentPrivateReply'] as const;
const changed = fields.filter((field) => Object.prototype.hasOwnProperty.call(patch, field));
if (changed.length === 0) throw new AutomationConfigValidationError();
for (const field of changed) {
if (!supportedFields.includes(field)) throw new AutomationConfigValidationError();
const value = patch[field];
if (typeof value !== 'string' || value.length > MAX_MESSAGE_LENGTH || DISALLOWED_CONTROLS.test(value)) {
throw new AutomationConfigValidationError();
}
}
}

export function automationConfigEtag(version: number): string { return `"automation-v${version}"`; }

export class AutomationConfigService {
constructor(
private readonly repository: AutomationConfigRepository,
private readonly assets: MetaAssetsRepository,
private readonly contexts: TenantContextService,
private readonly authorization: TenantAuthorizationService,
private readonly environment: MetaEnvironment,
) {}

async read(session: VerifiedPortalSession, contextRef: string, assetId: string, requestId: string): Promise<components['schemas']['AutomationChannelConfig']> {
const scope = await this.scope(session, contextRef);
await this.authorization.authorize(scope, 'RESOURCE_READ', scope.tenantId);
const asset = await this.findAsset(scope.tenantId, assetId);
const assetKind = automationConfigAssetKind(asset.kind);
if (!assetKind) throw new UnsupportedAutomationConfigFieldError();
const state = await this.repository.read({ tenantId: scope.tenantId, assetId: asset.assetRef });
return this.serialize(asset.assetRef, assetKind, state, isEditor(scope.roles), requestId);
}

async update(session: VerifiedPortalSession, contextRef: string, assetId: string, requestId: string,
ifMatchVersion: number, patch: AutomationConfigPatch): Promise<components['schemas']['AutomationChannelConfig']> {
const scope = await this.scope(session, contextRef);
await this.authorization.authorize(scope, 'MANAGE_AUTOMATION_CONFIG', scope.tenantId);
const asset = await this.findAsset(scope.tenantId, assetId);
const assetKind = automationConfigAssetKind(asset.kind);
if (!assetKind) throw new UnsupportedAutomationConfigFieldError();
const supportedFields = automationConfigFieldsForAssetKind(assetKind);
ifMatchVersion = Number.isSafeInteger(ifMatchVersion) && ifMatchVersion >= 0 ? ifMatchVersion : -1;
if (ifMatchVersion !== patch.version) throw new AutomationConfigVersionConflictError();
validatePatch(patch, supportedFields);
const state = await this.repository.update({
tenantId: scope.tenantId,
assetId: asset.assetRef,
actorId: scope.principalId,
requestId,
patch,
});
return this.serialize(asset.assetRef, assetKind, state, true, requestId);
}

private async scope(session: VerifiedPortalSession, contextRef: string) {
if (!isVerifiedPortalSession(session)) throw new IdentityDeniedError();
return this.contexts.resolveCurrent(session, contextRef);
}

private async findAsset(tenantId: string, assetId: string) {
if (!UUID.test(assetId)) throw new AutomationConfigNotFoundError();
const asset = await this.assets.findAssetCatalog({ tenantId, environment: this.environment, assetRef: assetId });
if (!asset) throw new AutomationConfigNotFoundError();
return asset;
}

private serialize(assetId: string, assetKind: components['schemas']['AutomationConfigAssetKind'],
state: Awaited<ReturnType<AutomationConfigRepository['read']>>, canEdit: boolean, requestId: string): components['schemas']['AutomationChannelConfig'] {
return Object.freeze({
assetId,
assetKind,
inheritanceMode: 'ASSET_ONLY',
supportedFields: automationConfigFieldsForAssetKind(assetKind),
privateWelcomeMessage: state.privateWelcomeMessage,
commentPublicReply: state.commentPublicReply,
commentPrivateReply: state.commentPrivateReply,
capabilities: Object.freeze({ canEdit, executable: false }),
version: state.version,
updatedAt: state.updatedAt,
projectionState: state.projectionState,
requestId,
});
}
}
1 change: 1 addition & 0 deletions apps/api/src/automation-config/automation-config.tokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const AUTOMATION_CONFIG_REPOSITORY = Symbol('AUTOMATION_CONFIG_REPOSITORY');
64 changes: 64 additions & 0 deletions apps/api/src/automation-config/automation-config.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { components } from '@ca-meta/contracts';

export type AutomationConfigField = components['schemas']['AutomationConfigField'];
export type AutomationConfigAssetKind = components['schemas']['AutomationConfigAssetKind'];
export type AutomationConfigProjectionState = components['schemas']['AutomationConfigProjectionState'];

export interface AutomationConfigState {
readonly privateWelcomeMessage: string | null;
readonly commentPublicReply: string | null;
readonly commentPrivateReply: string | null;
readonly version: number;
readonly updatedAt: string | null;
readonly projectionState: AutomationConfigProjectionState;
}

export interface AutomationConfigPatch {
readonly version: number;
readonly privateWelcomeMessage?: string;
readonly commentPublicReply?: string;
readonly commentPrivateReply?: string;
}

export interface AutomationConfigRepository {
read(input: { readonly tenantId: string; readonly assetId: string }): Promise<AutomationConfigState>;
update(input: {
readonly tenantId: string;
readonly assetId: string;
readonly actorId: string;
readonly requestId: string;
readonly patch: AutomationConfigPatch;
}): Promise<AutomationConfigState>;
}

export const AUTOMATION_CONFIG_FIELDS_BY_ASSET_KIND: Readonly<Record<AutomationConfigAssetKind, readonly AutomationConfigField[]>> = Object.freeze({
WHATSAPP_PHONE_NUMBER: Object.freeze(['privateWelcomeMessage'] as const),
FACEBOOK_PAGE: Object.freeze(['commentPublicReply', 'commentPrivateReply'] as const),
INSTAGRAM_BUSINESS_ACCOUNT: Object.freeze(['commentPublicReply', 'commentPrivateReply'] as const),
INSTAGRAM_CREATOR_ACCOUNT: Object.freeze(['commentPublicReply', 'commentPrivateReply'] as const),
});

export function automationConfigAssetKind(kind: string): AutomationConfigAssetKind | undefined {
return Object.prototype.hasOwnProperty.call(AUTOMATION_CONFIG_FIELDS_BY_ASSET_KIND, kind)
? kind as AutomationConfigAssetKind : undefined;
}

export function automationConfigFieldsForAssetKind(kind: AutomationConfigAssetKind): readonly AutomationConfigField[] {
return AUTOMATION_CONFIG_FIELDS_BY_ASSET_KIND[kind];
}

export class AutomationConfigNotFoundError extends Error {
constructor() { super('Automation configuration asset is unavailable.'); this.name = 'AutomationConfigNotFoundError'; }
}

export class UnsupportedAutomationConfigFieldError extends Error {
constructor() { super('The requested field is not available for this asset.'); this.name = 'UnsupportedAutomationConfigFieldError'; }
}

export class AutomationConfigValidationError extends Error {
constructor() { super('Automation configuration update is invalid.'); this.name = 'AutomationConfigValidationError'; }
}

export class AutomationConfigVersionConflictError extends Error {
constructor() { super('Automation configuration version changed.'); this.name = 'AutomationConfigVersionConflictError'; }
}
Loading
Loading