feat: add phone frequency controls and modularize codebase
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import ts from '../../api/node_modules/typescript/lib/typescript.js';
|
||||
|
||||
const root = process.cwd();
|
||||
const manifest = JSON.parse(
|
||||
readFileSync(resolve(root, 'docs/contracts/sms-config-r3-methods.json'), 'utf8'),
|
||||
);
|
||||
const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
|
||||
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
|
||||
|
||||
function loadSource(relativePath) {
|
||||
const filePath = resolve(root, relativePath);
|
||||
const source = readFileSync(filePath, 'utf8');
|
||||
const file = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
||||
return { source, file };
|
||||
}
|
||||
|
||||
function loadClass(relativePath, className) {
|
||||
const { source, file } = loadSource(relativePath);
|
||||
const declaration = file.statements.find(
|
||||
(statement) => ts.isClassDeclaration(statement) && statement.name?.text === className,
|
||||
);
|
||||
if (!declaration || !ts.isClassDeclaration(declaration)) {
|
||||
throw new Error(`${className} not found in ${relativePath}`);
|
||||
}
|
||||
return {
|
||||
source,
|
||||
file,
|
||||
methods: new Map(
|
||||
declaration.members.filter(ts.isMethodDeclaration).map((method) => [
|
||||
method.name.getText(file),
|
||||
method,
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const reverseReplacements = {
|
||||
application: {
|
||||
'this.lifecycle.markTimedOutDownstreamConnections': 'this.markTimedOutDownstreamConnections',
|
||||
'this.lifecycle.getApplicationDeactivationPreview': 'this.getApplicationDeactivationPreview',
|
||||
'this.lifecycle.writeOperationLog': 'this.writeOperationLog',
|
||||
},
|
||||
signature: {
|
||||
'this.reportValidation.validateSignatureReportValues': 'this.validateSignatureReportValues',
|
||||
'this.reportValidation.withReportRequirementSnapshot': 'this.withReportRequirementSnapshot',
|
||||
'this.reportValidation.syncSignatureReportValues': 'this.syncSignatureReportValues',
|
||||
'this.audit.createAuditRecord': 'this.createAuditRecord',
|
||||
},
|
||||
drainage: {
|
||||
'this.reportValidation.validateDrainageReportValues': 'this.validateDrainageReportValues',
|
||||
'this.reportValidation.activateDrainageReporting': 'this.activateDrainageReporting',
|
||||
'this.reportValidation.suspendDrainageReporting': 'this.suspendDrainageReporting',
|
||||
'this.audit.createAuditRecord': 'this.createAuditRecord',
|
||||
'this.audit.reviewDrainageInfo': 'this.reviewDrainageInfo',
|
||||
},
|
||||
template: {
|
||||
'this.audit.createAuditRecord': 'this.createAuditRecord',
|
||||
},
|
||||
audit: {
|
||||
'this.lifecycle.writeOperationLog': 'this.writeOperationLog',
|
||||
'this.reportValidation.activateDrainageReporting': 'this.activateDrainageReporting',
|
||||
'this.reportValidation.suspendDrainageReporting': 'this.suspendDrainageReporting',
|
||||
},
|
||||
reportValidation: {
|
||||
'this.applications.getApplicationReportFields': 'this.getApplicationReportFields',
|
||||
},
|
||||
};
|
||||
|
||||
function normalizeDomainBody(domain, method, file) {
|
||||
let printed = printer.printNode(ts.EmitHint.Unspecified, method.body, file);
|
||||
for (const [current, original] of Object.entries(reverseReplacements[domain] ?? {})) {
|
||||
printed = printed.split(current).join(original);
|
||||
}
|
||||
if (method.name.getText(file) === 'finalizeDisablingApplication') {
|
||||
printed = printed
|
||||
.split("SmsApplicationLifecycleService['getApplicationDeactivationPreview']")
|
||||
.join("SmsConfigService['getApplicationDeactivationPreview']");
|
||||
}
|
||||
return printed;
|
||||
}
|
||||
|
||||
const domainMethods = new Map();
|
||||
for (const [domain, definition] of Object.entries(manifest.domains)) {
|
||||
const loaded = loadClass(definition.file, definition.className);
|
||||
for (const name of definition.methods) {
|
||||
const method = loaded.methods.get(name);
|
||||
if (!method || !method.body) throw new Error(`Missing R3 domain method: ${domain}.${name}`);
|
||||
if (domainMethods.has(name)) throw new Error(`Duplicate R3 domain method: ${name}`);
|
||||
domainMethods.set(name, { domain, method, file: loaded.file, source: loaded.source });
|
||||
}
|
||||
if (loaded.methods.size !== definition.methods.length) {
|
||||
throw new Error(`Unexpected method found in R3 domain: ${domain}`);
|
||||
}
|
||||
}
|
||||
|
||||
const expectedMethods = [...manifest.publicMethods, ...manifest.internalMethods];
|
||||
for (const expected of expectedMethods) {
|
||||
const actual = domainMethods.get(expected.name);
|
||||
if (!actual) throw new Error(`R3 implementation missing: ${expected.name}`);
|
||||
if (actual.domain !== expected.domain) {
|
||||
throw new Error(`${expected.name} moved to unexpected R3 domain`);
|
||||
}
|
||||
const bodyHash = sha256(normalizeDomainBody(actual.domain, actual.method, actual.file));
|
||||
if (bodyHash !== expected.canonicalBodySha256) {
|
||||
throw new Error(`${expected.name} implementation changed during R3 split`);
|
||||
}
|
||||
}
|
||||
if (domainMethods.size !== expectedMethods.length) {
|
||||
throw new Error('Unexpected R3 implementation method found');
|
||||
}
|
||||
|
||||
const facade = loadClass('api/src/sms-config/sms-config.service.ts', 'SmsConfigService');
|
||||
const facadePublicMethods = new Map(
|
||||
[...facade.methods].filter(([, method]) =>
|
||||
!method.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.PrivateKeyword),
|
||||
),
|
||||
);
|
||||
for (const expected of manifest.publicMethods) {
|
||||
const method = facadePublicMethods.get(expected.name);
|
||||
if (!method || !method.body) throw new Error(`SmsConfigService facade method missing: ${expected.name}`);
|
||||
const signature = facade.source.slice(method.getStart(facade.file), method.body.getStart(facade.file)).trim();
|
||||
if (signature !== expected.signature) throw new Error(`${expected.name} facade signature changed`);
|
||||
}
|
||||
if (facadePublicMethods.size !== manifest.publicMethods.length) {
|
||||
throw new Error('Unexpected SmsConfigService facade method found');
|
||||
}
|
||||
|
||||
const contracts = loadSource('api/src/sms-config/sms-config.contracts.ts');
|
||||
const actualContracts = new Map(
|
||||
contracts.file.statements
|
||||
.filter((statement) => ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement))
|
||||
.map((statement) => [statement.name.text, sha256(statement.getText(contracts.file))]),
|
||||
);
|
||||
for (const expected of manifest.contracts) {
|
||||
if (actualContracts.get(expected.name) !== expected.sha256) {
|
||||
throw new Error(`SMS config contract changed during R3 split: ${expected.name}`);
|
||||
}
|
||||
}
|
||||
if (actualContracts.size !== manifest.contracts.length) {
|
||||
throw new Error('Unexpected SMS config contract found');
|
||||
}
|
||||
|
||||
const helpers = loadSource('api/src/sms-config/sms-config.helpers.ts');
|
||||
const actualHelpers = new Map(
|
||||
helpers.file.statements
|
||||
.filter(
|
||||
(statement) =>
|
||||
ts.isInterfaceDeclaration(statement) ||
|
||||
ts.isTypeAliasDeclaration(statement) ||
|
||||
ts.isFunctionDeclaration(statement) ||
|
||||
ts.isVariableStatement(statement),
|
||||
)
|
||||
.map((statement) => [
|
||||
ts.isVariableStatement(statement)
|
||||
? statement.declarationList.declarations[0].name.getText(helpers.file)
|
||||
: statement.name.text,
|
||||
sha256(statement.getText(helpers.file).replace(/^export\s+/, '')),
|
||||
]),
|
||||
);
|
||||
for (const expected of [...manifest.helpers, ...manifest.sharedDeclarations]) {
|
||||
if (actualHelpers.get(expected.name) !== expected.sha256) {
|
||||
throw new Error(`SMS config helper changed during R3 split: ${expected.name}`);
|
||||
}
|
||||
}
|
||||
if (actualHelpers.size !== manifest.helpers.length + manifest.sharedDeclarations.length) {
|
||||
throw new Error('Unexpected SMS config helper found');
|
||||
}
|
||||
|
||||
for (const controller of [
|
||||
'api/src/sms-config/admin-sms-config.controller.ts',
|
||||
'api/src/sms-config/client-sms-config.controller.ts',
|
||||
'api/src/send-chain/gateway-events.controller.ts',
|
||||
]) {
|
||||
const loaded = loadSource(controller);
|
||||
for (const statement of loaded.file.statements.filter(ts.isImportDeclaration)) {
|
||||
if (!statement.moduleSpecifier.getText(loaded.file).includes('sms-config.service')) continue;
|
||||
const names = statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause.namedBindings)
|
||||
? statement.importClause.namedBindings.elements.map((element) => element.name.text)
|
||||
: [];
|
||||
if (names.some((name) => name.endsWith('Dto') || name.endsWith('ListQuery'))) {
|
||||
throw new Error(`${controller} still imports DTOs from the implementation facade`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`R3 SMS configuration facade verified: ${manifest.publicMethods.length} public methods, ${manifest.internalMethods.length} internal methods, ${manifest.contracts.length} contracts and ${manifest.helpers.length + manifest.sharedDeclarations.length} helpers/constants; implementations unchanged.`,
|
||||
);
|
||||
Reference in New Issue
Block a user