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:
parent
9450dd34db
commit
5330fecf43
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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' }) }
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user