- 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
295 lines
7.9 KiB
TypeScript
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
}
|