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; private customTemplates: Map; 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 ): 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, schema: TemplateVariable[] ): string { const varMap = new Map(); 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, '''); } 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, }; } }