feat(campaigns/wizard): per-language template override

Two new dropdowns in Step 1 ("Template français" / "Template anglais")
populated from /campaigns/templates filtered by suffix (-fr / -en).
Selection is stored on campaign.params.template_fr / .template_en
and the worker resolves the actual path via a new resolveTemplatePath
helper:

  1. params.template_<lang>  (per-lang override, set here)
  2. params.template_path    (legacy single-template campaign override)
  3. templateForLanguage()    (default gift-email-<lang>.html)

Defensive name regex inside resolveTemplatePath blocks path traversal —
operator can pick any *-fr / *-en template that exists, nothing else.

The Step 3 summary list now shows which template will actually ship
per language so the operator can sanity-check before launch.

Use cases: seasonal variants (gift-email-2026-summer-fr), A/B tests,
draft templates that aren't ready to be the default yet.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-05-22 09:46:58 -04:00
parent 9450dd34db
commit 5330fecf43
2 changed files with 69 additions and 4 deletions

View File

@ -68,6 +68,21 @@
<q-input v-model="params.expiry" label="Expiration (texte affiché)" outlined dense class="col-12 col-md-6" placeholder="31 décembre 2026" />
<q-input v-model.number="params.throttle_ms" type="number" label="Throttle (ms entre envois)" outlined dense class="col-12 col-md-6" />
<q-select v-model="params.multi" :options="multiOptions" emit-value map-options label="Emails multiples (couples)" outlined dense class="col-12 col-md-6" />
<!-- Per-language template override. Defaults to gift-email-fr /
gift-email-en. Lets the operator pick seasonal or A/B
variants without touching the code. -->
<q-select v-model="params.template_fr" :options="frTemplateOptions"
label="🇫🇷 Template français" outlined dense
class="col-12 col-md-6" emit-value map-options
:loading="loadingTemplates">
<q-tooltip>Template envoyé aux destinataires marqués FR. Tous les templates avec suffixe -fr sont listés.</q-tooltip>
</q-select>
<q-select v-model="params.template_en" :options="enTemplateOptions"
label="🇺🇸 Template anglais" outlined dense
class="col-12 col-md-6" emit-value map-options
:loading="loadingTemplates">
<q-tooltip>Template envoyé aux destinataires marqués EN.</q-tooltip>
</q-select>
</div>
</q-card-section>
</q-card>
@ -324,6 +339,8 @@
<q-item><q-item-section side>Engagement</q-item-section><q-item-section>{{ params.commitment_months }} mois</q-item-section></q-item>
<q-item><q-item-section side>Sujet</q-item-section><q-item-section>{{ params.subject }}</q-item-section></q-item>
<q-item><q-item-section side>Expéditeur</q-item-section><q-item-section>{{ params.from }}</q-item-section></q-item>
<q-item><q-item-section side>Template FR</q-item-section><q-item-section><code>{{ params.template_fr }}</code></q-item-section></q-item>
<q-item><q-item-section side>Template EN</q-item-section><q-item-section><code>{{ params.template_en }}</code></q-item-section></q-item>
<q-item><q-item-section side>Throttle</q-item-section><q-item-section>{{ params.throttle_ms }} ms entre envois (≈ {{ Math.round((60 / (params.throttle_ms / 1000)) || 0) }} emails/min)</q-item-section></q-item>
<q-item><q-item-section side>Durée estimée</q-item-section><q-item-section> {{ estimatedMinutes }} min</q-item-section></q-item>
</q-list>
@ -443,10 +460,10 @@
</template>
<script setup>
import { ref, computed } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useQuasar } from 'quasar'
import { parseCsvs, createCampaign, sendCampaign, previewTemplate } from 'src/api/campaigns'
import { parseCsvs, createCampaign, sendCampaign, previewTemplate, listTemplates } from 'src/api/campaigns'
const $q = useQuasar()
const router = useRouter()
@ -466,7 +483,38 @@ const params = ref({
expiry: '',
throttle_ms: 600,
multi: 'first',
// Per-language template selection. Defaults match the canonical templates;
// operator can switch to a variant (e.g. seasonal) per campaign.
template_fr: 'gift-email-fr',
template_en: 'gift-email-en',
})
// Template dropdowns (populated from hub on mount). Filter by language suffix:
// names ending in -fr go to FR list, -en to EN list, others ignored.
const loadingTemplates = ref(false)
const frTemplateOptions = ref([{ label: 'gift-email-fr (défaut)', value: 'gift-email-fr' }])
const enTemplateOptions = ref([{ label: 'gift-email-en (défaut)', value: 'gift-email-en' }])
async function loadTemplateLists () {
loadingTemplates.value = true
try {
const tpls = await listTemplates()
const fr = [], en = []
for (const t of tpls) {
const n = t.name || t
if (typeof n !== 'string') continue
const label = `${n}${t.size ? ` (${Math.round(t.size / 1024)} KB)` : ''}`
if (n.endsWith('-fr')) fr.push({ label, value: n })
else if (n.endsWith('-en')) en.push({ label, value: n })
}
if (fr.length) frTemplateOptions.value = fr
if (en.length) enTemplateOptions.value = en
} catch (e) {
$q.notify({ type: 'warning', message: 'Liste des templates non chargée: ' + e.message })
} finally {
loadingTemplates.value = false
}
}
onMounted(loadTemplateLists)
const multiOptions = [
{ label: '1er email seulement (1 cadeau/foyer)', value: 'first' },
{ label: 'Séparer en 2 rangées (1 cadeau/personne)', value: 'split' },

View File

@ -704,6 +704,23 @@ function templateForLanguage (lang) {
return DEFAULT_TEMPLATE
}
// Per-campaign per-language template override. Used when the wizard lets
// the operator pick a non-default variant (e.g. seasonal: gift-email-2026-summer-fr).
// The override is stored on params as a bare template name (no extension,
// no path) — we resolve to an absolute path here with a defensive name
// regex to block path traversal.
function resolveTemplatePath (p, lang) {
const safe = (lang || 'fr').toLowerCase().split('-')[0]
const tplName = safe === 'en' ? p?.template_en : p?.template_fr
if (tplName && /^[a-z0-9-]+$/i.test(tplName)) {
const candidate = path.join(TEMPLATES_DIR, tplName + '.html')
if (fs.existsSync(candidate)) return candidate
}
// Legacy single-template campaign-wide override
if (p?.template_path) return p.template_path
return templateForLanguage(lang)
}
function templatePath (name) {
if (!isValidTemplateName(name)) {
throw new Error(`template name invalid or not allowed: ${name}`)
@ -791,7 +808,7 @@ async function sendCampaignAsync (id) {
// recipient. Cache map keyed by resolved template path.
const tplCache = new Map()
const getTpl = (lang) => {
const tplPath = p.template_path || templateForLanguage(lang)
const tplPath = resolveTemplatePath(p, lang)
if (!tplCache.has(tplPath)) {
tplCache.set(tplPath, fs.readFileSync(tplPath, 'utf8'))
}
@ -1558,7 +1575,7 @@ async function handle (req, res, method, path) {
if (!r) return json(res, 404, { error: 'recipient not found' })
const p = c.params || {}
const lang = (r.language || 'fr').toLowerCase().split('-')[0]
const tplPath = p.template_path || templateForLanguage(lang)
const tplPath = resolveTemplatePath(p, lang)
let tplText
try { tplText = fs.readFileSync(tplPath, 'utf8') }
catch { return json(res, 500, { error: 'template missing' }) }