Files
Kordant/packages/shared-notifications/src/services/template.service.ts
Senior Engineer 03276dde2d Add cross-service alert correlation system FRE-4500
- Unified alert types (AlertSource, AlertCategory, CorrelationStatus, EntityType)
- NormalizedAlert and CorrelationGroup Prisma models
- AlertNormalizer for all 4 services (DarkWatch, SpamShield, VoicePrint, CallAnalysis)
- CorrelationEngine with temporal + entity-based correlation detection
- CorrelationService orchestrator with dashboard API
- Correlation API routes (/api/v1/correlation/*)
- Service emitters wired to DarkWatch, SpamShield, VoicePrint
- pnpm workspace config for monorepo
2026-05-02 01:10:44 -04:00

295 lines
7.9 KiB
TypeScript

import type {
TemplateDefinition,
ResolvedTemplate,
TemplateResolutionOptions,
TemplateCacheEntry,
TemplateStore,
TemplateVariable,
} from '../types/template.types';
import { AllDefaultTemplates, DEFAULT_LOCALE } from '../templates/default-templates';
const CACHE_TTL_MS = 300000;
const VARIABLE_PATTERN = /\{\{(\w+)\}\}/g;
const TRUSTED_DOMAINS = ['shieldai.com', 'shieldai.app', 'app.shieldai.com', 'api.shieldai.com'];
export class TemplateService {
private static instance: TemplateService;
private templateStore: TemplateStore;
private cache: Map<string, TemplateCacheEntry>;
private customTemplates: Map<string, TemplateDefinition[]>;
private constructor() {
this.templateStore = new Map();
this.cache = new Map();
this.customTemplates = new Map();
this.initializeDefaults();
}
static getInstance(): TemplateService {
if (!TemplateService.instance) {
TemplateService.instance = new TemplateService();
}
return TemplateService.instance;
}
private initializeDefaults() {
for (const template of AllDefaultTemplates) {
const key = this.getStoreKey(template.id, template.locale);
if (!this.templateStore.has(template.id)) {
this.templateStore.set(template.id, new Map());
}
this.templateStore.get(template.id)!.set(template.locale, template);
}
}
private getStoreKey(templateId: string, locale: string): string {
return `${templateId}:${locale}`;
}
private getCacheKey(templateId: string, locale: string): string {
return `${templateId}:${locale}`;
}
registerTemplate(template: TemplateDefinition): void {
if (!this.templateStore.has(template.id)) {
this.templateStore.set(template.id, new Map());
}
const localeMap = this.templateStore.get(template.id)!;
localeMap.set(template.locale, template);
this.invalidateCache(template.id, template.locale);
}
registerTemplates(templates: TemplateDefinition[]): void {
for (const template of templates) {
this.registerTemplate(template);
}
}
resolveTemplate(options: TemplateResolutionOptions): ResolvedTemplate | null {
const { templateId, locale = DEFAULT_LOCALE, variables, fallbackLocale = DEFAULT_LOCALE } = options;
const cached = this.getCached(templateId, locale);
if (cached) {
return this.renderTemplate(cached, variables || {});
}
const template = this.findTemplate(templateId, locale, fallbackLocale);
if (!template) {
return null;
}
this.cacheTemplate(template);
return this.renderTemplate(template, variables || {});
}
private findTemplate(
templateId: string,
locale: string,
fallbackLocale: string
): TemplateDefinition | null {
const localeMap = this.templateStore.get(templateId);
if (!localeMap) {
return null;
}
const normalizedLocale = this.normalizeLocale(locale);
if (localeMap.has(normalizedLocale)) {
return localeMap.get(normalizedLocale)!;
}
const languageCode = normalizedLocale.split('-')[0];
for (const [key, template] of localeMap.entries()) {
if (key.split('-')[0] === languageCode && key !== normalizedLocale) {
return template;
}
}
if (localeMap.has(fallbackLocale)) {
return localeMap.get(fallbackLocale)!;
}
for (const [key] of localeMap.entries()) {
if (key.split('-')[0] === fallbackLocale.split('-')[0]) {
return localeMap.get(key)!;
}
}
const firstTemplate = localeMap.values().next().value;
return firstTemplate || null;
}
private normalizeLocale(locale: string): string {
const parts = locale.toLowerCase().split(/[-_]/);
if (parts.length === 1) {
return parts[0];
}
return parts[0] + '-' + parts[1].toUpperCase();
}
private renderTemplate(
template: TemplateDefinition,
variables: Record<string, unknown>
): ResolvedTemplate {
const subject = template.subject
? this.substituteVariables(template.subject, variables, template.variables)
: template.subject;
const body = this.substituteVariables(
template.body,
variables,
template.variables
);
const htmlBody = template.htmlBody
? this.substituteVariables(template.htmlBody, variables, template.variables)
: template.htmlBody;
return {
id: template.id,
subject,
body,
htmlBody,
locale: template.locale,
channel: template.channel,
};
}
private substituteVariables(
text: string,
variables: Record<string, unknown>,
schema: TemplateVariable[]
): string {
const varMap = new Map<string, TemplateVariable>();
for (const v of schema) {
varMap.set(v.name, v);
}
const isHtmlContext = text.includes('<') && text.includes('>');
return text.replace(VARIABLE_PATTERN, (match, varName) => {
const value = variables[varName];
if (value !== undefined) {
const stringValue = String(value);
if (this.isUrlVariable(varName)) {
return this.validateUrl(stringValue);
}
return isHtmlContext ? this.escapeHtml(stringValue) : stringValue;
}
const schemaVar = varMap.get(varName);
if (schemaVar?.defaultValue !== undefined) {
const defaultValue = String(schemaVar.defaultValue);
if (this.isUrlVariable(varName)) {
return this.validateUrl(defaultValue);
}
return isHtmlContext ? this.escapeHtml(defaultValue) : defaultValue;
}
return match;
});
}
private escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
private validateUrl(url: string): string {
try {
const parsed = new URL(url);
if (TRUSTED_DOMAINS.includes(parsed.hostname)) {
return url;
}
return '/';
} catch {
return '/';
}
}
private isUrlVariable(varName: string): boolean {
return varName.endsWith('_url');
}
private getCached(templateId: string, locale: string): TemplateDefinition | null {
const cacheKey = this.getCacheKey(templateId, locale);
const entry = this.cache.get(cacheKey);
if (!entry) {
return null;
}
const age = Date.now() - entry.resolvedAt.getTime();
if (age > entry.ttl) {
this.cache.delete(cacheKey);
return null;
}
return entry.template;
}
private cacheTemplate(template: TemplateDefinition): void {
const cacheKey = this.getCacheKey(template.id, template.locale);
this.cache.set(cacheKey, {
template,
resolvedAt: new Date(),
ttl: CACHE_TTL_MS,
});
}
private invalidateCache(templateId: string, locale: string): void {
const cacheKey = this.getCacheKey(templateId, locale);
this.cache.delete(cacheKey);
}
getAvailableLocales(templateId: string): string[] {
const localeMap = this.templateStore.get(templateId);
if (!localeMap) {
return [];
}
return Array.from(localeMap.keys());
}
getTemplateIds(): string[] {
return Array.from(this.templateStore.keys());
}
getTemplateInfo(templateId: string): {
id: string;
locales: string[];
channel: string;
category: string;
variables: string[];
} | null {
const localeMap = this.templateStore.get(templateId);
if (!localeMap) {
return null;
}
const firstTemplate = localeMap.values().next().value;
if (!firstTemplate) {
return null;
}
return {
id: templateId,
locales: Array.from(localeMap.keys()),
channel: firstTemplate.channel,
category: firstTemplate.category,
variables: firstTemplate.variables.map(v => v.name),
};
}
clearCache(): void {
this.cache.clear();
}
getCacheStats(): { size: number; totalTemplates: number } {
return {
size: this.cache.size,
totalTemplates: this.templateStore.size,
};
}
}