Imported from lbolanos/emaus (
.ruler/skills/template-variables/SKILL.md). Install upstream withnpx skills add lbolanos/emaus --skill template-variables. Copyright stays with the author.
Template Variables — sistema canónico {scope.var}
Los placeholders en plantillas de mensaje (message_templates + global_message_templates) siguen una sintaxis única: single-brace + scope-dotted, p. ej. {participant.firstName}, {retreat.startDate}, {community.name}.
NO usamos mustache ({{var}}). La única excepción es retro-compat en communityService.renderTemplate que acepta ambos formatos para overrides legacy — ver sección "Doble syntax".
Cuándo cargar este skill
- Agregar una variable nueva a un scope existente (p. ej.
{retreat.foo}). - Agregar un scope nuevo (p. ej.
{house.X},{user.X}en contexto cliente). - Bug: una variable queda como texto literal en el envío.
- Diseñar un endpoint server-side que renderice plantillas (similar a
userManagementMailer). - Auditar variables fantasma (listadas en picker pero no implementadas).
Arquitectura
Pieza canónica: @repo/utils
packages/utils/src/index.ts define:
interface ParticipantData,RetreatData,CommunityData— los datos que las plantillas pueden interpolar.buildParticipantReplacements(data),buildRetreatReplacements(data),buildCommunityReplacements(data)— mapping{scope.var} → string.replaceParticipantVariables,replaceRetreatVariables,replaceCommunityVariables— el reemplazo individual (con fallback a mock cuando data es null/undefined, para previews).replaceAllVariables(message, participant, retreat, contactKey?, community?)— entry point que combina los tres scopes.findEmptyVariables(...)— para mostrar "variables sin datos" en MessageDialog.
Regla de oro: si una variable debe resolver en el cliente (MessageDialog, BaseMessageTemplateModal preview, comunicación a participantes), tiene que estar en uno de los build*Replacements.
Scopes disponibles
| Scope | Cuándo usar | Ejemplos |
|---|---|---|
participant.* |
Datos del destinatario (caminante/servidor) | firstName, nickname, cellPhone, email, emergencyContact1Name, palanqueroName, pickupLocation, dataDeleteUrl |
retreat.* |
Datos del retiro al que pertenece el participante | parish, startDate, endDate, cost, paymentInfo, thingsToBringNotes, closingChurchName, walkerArrivalTime, next_meeting_date |
community.* |
Datos de comunidad (solo en plantillas community-scoped) | name, meetingTitle, meetingDate, attendanceLink, requesterName, acceptUrl |
table.* |
Roster de una mesa armada (solo en el briefing de mesa) | name, liderName, walkersRoster |
preparations.* |
Calendario de preparaciones, solo en documentos, no en mensajes | table, count, firstDate, lastDate |
El scope preparations.* — el que NO va en el picker
{preparations.table} devuelve una tabla markdown entera, no un valor escalar. Vive en
@repo/utils como los demás (buildPreparationsTableMarkdown, replacePreparationsVariables,
resolvePreparationDocumentContent), pero con tres diferencias que hay que respetar:
- No se registra en el picker de
BaseMessageTemplateModal. Un bloque de tabla no tiene cómo resolverse dentro de un WhatsApp o un correo: aparecería en el selector y produciría basura. El picker declara sus variables una a una, así que basta con no añadirla. - No cae a datos mock. El resto de scopes inventan un participante/retiro de ejemplo para el
preview; un calendario inventado dentro de un documento que el equipo va a imprimir es peor que
un aviso, así que sin entradas devuelve
_El calendario de preparaciones aún no se ha generado._. - El caller pasa las filas ya ordenadas (mismo contrato que
table.*): el orden lo decidesortEntriesdel servicio, y@repo/utilssolo formatea.
Dónde se resuelve: retreatPreparationService.withRenderedDocuments() lo aplica en cada lectura y
expone el resultado como renderedContent, sin tocar content, que sigue siendo la plantilla.
Ver docs/features/retreat-preparations.md.
Cómo se conecta el cliente
MessageDialog.vue:
- Carga el template seleccionado.
- Arma
participantDatadesdeprops.participant. - Arma
retreatDatadesderetreatStore.selectedRetreat(modo retreat) o un partial concurrentCommunity.name(modo community). - Pre-resuelve
nextMeetingDatellamandoGET /api/participants/:id/next-meetingy lo inyecta enretreatData.nextMeetingDate. - Si scope=community, arma
communityDatadesdecurrentCommunity. - Llama
replaceAllVariables(message, participant, retreat, contactKey, community).
BaseMessageTemplateModal.vue (editor de plantillas):
- Mismo patrón para el preview. Cuando hay
selectedParticipant, dispara la misma llamadagetParticipantNextMeeting()para que el preview muestre el mismo texto que MessageDialog enviaría. - Cuando no hay participante,
getMockRetreat()provee placeholders realistas.
Variables server-only
Algunas variables NO viven en @repo/utils porque solo aplican a correos automáticos que el servidor emite vía apps/api/src/services/userManagementMailer.ts (USER_INVITATION, RETREAT_SHARED_NOTIFICATION, PASSWORD_RESET, SYS_*):
| Variable | Contexto |
|---|---|
{user.name}, {user.displayName}, {user.email}, {user.nickname} |
Datos del User destinatario |
{inviterName} |
Quien invitó al user |
{shareLink}, {invitationUrl} |
URL para aceptar invitación |
{resetToken} |
Token de password reset |
{role.name}, {role} |
Nombre del rol asignado |
El picker en BaseMessageTemplateModal las muestra bajo la categoría "Sistema" con badge Server-only y descripción explicando que NO resuelven en MessageDialog manual — solo en correos automáticos del backend.
Si alguien pega {user.name} en una plantilla GENERAL y la envía a un participante via MessageDialog, queda literal. Esto es por diseño: no hay un "user destinatario" en ese flujo.
{custom_message} — placeholder editable, NO variable
Es un marcador en la plantilla GENERAL que el usuario reemplaza tipeando su mensaje real en MessageDialog antes de enviar. No resuelve automáticamente. Se categoriza como "Placeholder editable" en el picker, no como variable.
Cómo agregar una variable nueva
Variable cliente (resuelve en MessageDialog y preview)
Caso: quiero {retreat.parish_address}.
-
Agregar al tipo en
packages/utils/src/index.ts:export interface RetreatData { // ... parish_address?: string; } -
Agregar al builder (
buildRetreatReplacements):'retreat.parish_address': retreatData.parish_address || '', -
Agregar al mock (
getMockRetreat) para que el preview muestre algo realista:parish_address: 'Av. Insurgentes Sur 1234, CDMX', -
Exponer en el picker (
BaseMessageTemplateModal.vue,retreatVariables):{ key: 'parish_address', label: 'Dirección de la parroquia' }, -
Si el dato viene de una API call extra (no en
selectedRetreat): seguir el patrón degetParticipantNextMeeting— endpoint + fetch enMessageDialogwatcher de open + inyección enretreatData.
Variable server-only (resuelve solo en correos automáticos)
- Agregar al
EmailTemplateDatainterface enuserManagementMailer.ts. - Agregar el reemplazo en
processTemplate()con.replace(/{varName}/g, data.varName || ''). - Exponer en
systemFlowVariablesouserVariablesdel picker enBaseMessageTemplateModal.
Doble sintaxis en communityService.renderTemplate
Solo este renderer acepta ambos formatos para retro-compat con plantillas community sembradas en mustache antes de la migration 20260518173857_NormalizeCommunityTemplateVariables. Hace dos pasadas:
{{var}}(mustache, legacy).{participant.firstName}/{community.X}(canónica).
Ambos pasan por escapeHtml para prevenir XSS. Para todo lo demás (cliente, otros renderers server-side), use canónica.
Variables fantasma — checklist
Antes de listar una variable en el picker, verificar:
- ¿Está en
build*Replacements(cliente) o enprocessTemplate(server)? - ¿Hay mock realista para preview?
- Si requiere fetch extra (como
nextMeetingDate), ¿hay endpoint + cliente que lo inyecte? - Si es server-only, ¿está categorizada como "Sistema" con badge?
Si la respuesta a alguna es "no" y vas a listarla — está rota y aparecerá literal en el envío. Mejor no listarla que listarla rota.
Variables fantasma históricas (ya removidas)
{retreat.fecha_limite_palanca}— removida en migration20260518181652. Reemplazada por{retreat.startDate}enPALANCA_REQUEST.{participant.hora_llegada}— migrada a{retreat.walkerArrivalTime}/{retreat.serverArrivalTimeFriday}en migration20251025181628. Eliminada del picker en este ciclo.
Endpoints relevantes
-
GET /api/participants/:id/next-meeting?communityId=X— devuelve{ nextMeetingDate, formattedDate, title, communityId, communityName }. Comportamiento:- Con
communityId: restringe a esa comunidad. Devuelve nulls si el participante no es miembro activo/pendiente ahí. Este es el caso correcto cuando se envía un mensaje desde un contexto de comunidad específica — el participante puede estar en varias y queremos la próxima reunión DE esa comunidad, no la más temprana entre todas. - Sin
communityId: busca entre todas las comunidades del participante y devuelve la más temprana. Fallback para retreat-context donde no hay comunidad asociada.
Implementado en
participantService.findNextMeetingForParticipant(id, communityId?). Tests:apps/api/src/tests/services/participantNextMeeting.test.ts(11 casos: con/sin scope, multi-membership, estados declinados, recurrence templates).El cliente (
MessageDialog.vueyBaseMessageTemplateModal.vue) siempre pasacommunityIdcuando hay contexto de comunidad (props.communityId / props.template.communityId). En retreat context lo omite. - Con
Migrations relacionadas
20260516100000_SeedCommunityMessageTemplates— siembra plantillas community con mustache (legacy).20260518173857_NormalizeCommunityTemplateVariables— reescribe mustache → canónica.20260518175839_RewriteSeedTemplatesWithWarmerVoice— reescribe todos los templates seed con voz más personal +{community.name}donde había "tu comunidad de Emaús" genérico.20260518181652_FixPalancaRequestPhantomVar— saca{retreat.fecha_limite_palanca}de PALANCA_REQUEST.20251025181628_UpdateMessageTemplatesArrivalTimeVariables— migra{participant.hora_llegada}→{retreat.walkerArrivalTime}/{retreat.serverArrivalTimeFriday}.
Todas son conservadoras: comparan message = '<seed exacto>' antes de UPDATE, preservando customizaciones del usuario.