feat(campaigns): auto-clean first/last names (QC accents + compound split)
The Map CSV migrated from the legacy ERP carries names with two common
defects: missing French accents (Stephane, Andre, Frederic), and
compound first names that were typed without a separator (Marcandre,
Mariejosee, Jeanphilippe). Sending an email "Bonjour stephane," instead
of "Bonjour Stéphane," reads as sloppy automation. Fix both at parse
time so the user sees the corrected names in Step 2 and can override
inline if the auto-cleaner got it wrong.
Backend (lib/campaigns.js):
- FR_NAME_FIXES — 100+ entry dictionary mapping lowercase no-accent
Québec first names to their canonical accented form (André, Stéphane,
Frédéric, Geneviève, Hélène, Joséée, etc.). Sourced from MIQ baby
names + older-generation curation.
- COMPOUND_PARTS — list of common name parts (jean, marie, anne, marc,
philippe, françois, etc.) that combine into QC compound first names.
When two parts appear concatenated with no separator, the cleaner
splits and hyphenates them. Example: "Marcandre" → ["marc","andre"]
→ "Marc-André" (dictionary then applies accent).
- titleCaseToken — proper Title Case respecting apostrophes (O'Brien,
L'Heureux) and hyphens (Marie-Ève). Uses \p{L} Unicode class so it
works on accented chars correctly.
- cleanName(raw) — full pipeline: trim → Title Case → dictionary
lookup per word → compound split fallback. Applied to firstname AND
lastname in parseMapCsv.
- nameWarning(name) — heuristic flag for cases the cleaner couldn't
confidently handle: digit in name, single letter, abnormally long
without separator (likely two stuck names not in COMPOUND_PARTS).
Returns a short FR description for the UI tooltip.
- parseMapCsv now returns firstname/lastname (cleaned) + firstname_raw/
lastname_raw (original from CSV) + cleaned_changed bool + name_warnings
per recipient. UI uses these to show before/after + flags.
UI (CampaignNewPage Step 2):
- New counter card "Noms à vérifier" — count of recipients with at least
one nameWarning. Only renders if > 0.
- Info banner above the recipients table:
"X nom(s) auto-corrigés (...) Y nom(s) suspects (...)"
- Per-row icons in the firstname + lastname columns:
• ⚠ amber WARNING — cleaner flagged this name as suspicious
(tooltip shows the reason: "deux prénoms collés", "contient un
chiffre", etc.)
• ✨ green AUTO_FIX_HIGH — auto-cleaner changed something at parse
time (tooltip shows the original raw value)
Both icons are tooltip-only — no action required.
- Click any name cell → q-popup-edit opens an inline input. Type the
correction, Enter saves. ESC cancels. This is the manual override
path for any name the auto-cleaner mishandled.
Tests (manual via end-to-end smoke against prod):
STEPHANE TREMBLAY → Stéphane Tremblay ✓ accent + Title Case
marie tremblay → Marie Tremblay ✓ Title Case only
Marcandre Boileau → Marc-André Boileau ✓ compound + accent
Jean Francois Lebrun → Jean François Lebrun ✓ accent only
Mariejosee Lapierre → Marie-Josée Lapierre ✓ compound + double accent
Andre LAPRISE → André Laprise ✓ both fixed
Helene St-Pierre → Hélène St-Pierre ✓ accent, hyphen preserved
Frederic O'Brien → Frédéric O'Brien ✓ accent, apostrophe preserved
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
2b85735006
commit
d897bcedb4
|
|
@ -119,8 +119,27 @@
|
|||
<div class="text-caption text-grey-7">Liens en surplus</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
<q-card flat bordered class="col-6 col-md" v-if="namesNeedingReview">
|
||||
<q-card-section class="text-center q-pa-sm">
|
||||
<div class="text-h5 text-amber-9">{{ namesNeedingReview }}</div>
|
||||
<div class="text-caption text-grey-7">Noms à vérifier</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</div>
|
||||
|
||||
<!-- Auto-clean summary — informational, not blocking -->
|
||||
<q-banner v-if="namesAutoCorrected || namesNeedingReview" class="bg-blue-1 text-blue-9 q-mb-sm" rounded dense>
|
||||
<template v-slot:avatar><q-icon name="auto_fix_high" /></template>
|
||||
<span v-if="namesAutoCorrected">
|
||||
<strong>{{ namesAutoCorrected }} nom(s) auto-corrigés</strong> (Title Case, accents québécois,
|
||||
prénoms composés séparés). L'icône ✨ verte dans le tableau indique les changements.
|
||||
</span>
|
||||
<span v-if="namesNeedingReview" :class="namesAutoCorrected ? 'q-ml-md' : ''">
|
||||
<strong>{{ namesNeedingReview }} nom(s) suspects</strong> — icône ⚠ amber : cliquer la cellule
|
||||
pour éditer en place.
|
||||
</span>
|
||||
</q-banner>
|
||||
|
||||
<!-- Imbalance banner: explicit explanation of what the imbalance means -->
|
||||
<q-banner v-if="unpairedContacts.length || unusedGifts.length" class="bg-orange-1 text-orange-9 q-mb-md" rounded>
|
||||
<template v-slot:avatar><q-icon name="warning" /></template>
|
||||
|
|
@ -151,6 +170,44 @@
|
|||
<template v-slot:body-cell-row_index="props">
|
||||
<q-td :props="props" class="text-grey-6">#{{ props.row.row_index }}</q-td>
|
||||
</template>
|
||||
<!-- Editable firstname cell with auto-clean indicators.
|
||||
Click the cell → q-popup-edit opens, type new value, Enter saves.
|
||||
Icons (left of name):
|
||||
⚠ amber = nameWarning() heuristic flagged this (e.g. "deux prénoms collés")
|
||||
✨ green = auto-cleaner changed something at parse-time
|
||||
(Title Case, accent restoration, compound split) -->
|
||||
<template v-slot:body-cell-firstname="props">
|
||||
<q-td :props="props" style="cursor:pointer">
|
||||
<q-icon v-if="props.row.name_warnings?.firstname" name="warning" color="amber-9" size="14px" class="q-mr-xs">
|
||||
<q-tooltip>{{ props.row.name_warnings.firstname }}</q-tooltip>
|
||||
</q-icon>
|
||||
<q-icon v-else-if="props.row.cleaned_changed && props.row.firstname !== props.row.firstname_raw"
|
||||
name="auto_fix_high" color="positive" size="14px" class="q-mr-xs">
|
||||
<q-tooltip>Auto-corrigé depuis "<strong>{{ props.row.firstname_raw }}</strong>"</q-tooltip>
|
||||
</q-icon>
|
||||
{{ props.row.firstname }}
|
||||
<q-popup-edit v-model="props.row.firstname" auto-save v-slot="scope" buttons label-set="OK" label-cancel="Annuler">
|
||||
<q-input v-model="scope.value" dense autofocus :model-value="scope.value"
|
||||
@keyup.enter="scope.set" @keyup.esc="scope.cancel" />
|
||||
</q-popup-edit>
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-lastname="props">
|
||||
<q-td :props="props" style="cursor:pointer">
|
||||
<q-icon v-if="props.row.name_warnings?.lastname" name="warning" color="amber-9" size="14px" class="q-mr-xs">
|
||||
<q-tooltip>{{ props.row.name_warnings.lastname }}</q-tooltip>
|
||||
</q-icon>
|
||||
<q-icon v-else-if="props.row.cleaned_changed && props.row.lastname !== props.row.lastname_raw"
|
||||
name="auto_fix_high" color="positive" size="14px" class="q-mr-xs">
|
||||
<q-tooltip>Auto-corrigé depuis "<strong>{{ props.row.lastname_raw }}</strong>"</q-tooltip>
|
||||
</q-icon>
|
||||
{{ props.row.lastname }}
|
||||
<q-popup-edit v-model="props.row.lastname" auto-save v-slot="scope" buttons label-set="OK" label-cancel="Annuler">
|
||||
<q-input v-model="scope.value" dense autofocus
|
||||
@keyup.enter="scope.set" @keyup.esc="scope.cancel" />
|
||||
</q-popup-edit>
|
||||
</q-td>
|
||||
</template>
|
||||
<template v-slot:body-cell-gift_url="props">
|
||||
<q-td :props="props">
|
||||
<a :href="props.row.gift_url" target="_blank" style="color:var(--q-primary); font-family:monospace; font-size:0.78rem">
|
||||
|
|
@ -380,6 +437,16 @@ const excludedCount = computed(() => recipients.value.filter(r => r.excluded).le
|
|||
// Net number of emails that will actually be fired off (paired AND not excluded)
|
||||
const sendableCount = computed(() => recipients.value.filter(r => !r.excluded && r.gift_url).length)
|
||||
|
||||
// Names that the auto-cleaner couldn't confidently fix. Heuristic warnings
|
||||
// from the backend (digit in name, two names possibly stuck together, etc.).
|
||||
// User should glance at these before sending.
|
||||
const namesNeedingReview = computed(() =>
|
||||
recipients.value.filter(r => r.name_warnings?.firstname || r.name_warnings?.lastname).length
|
||||
)
|
||||
const namesAutoCorrected = computed(() =>
|
||||
recipients.value.filter(r => r.cleaned_changed).length
|
||||
)
|
||||
|
||||
// FR / EN breakdown of the sendable recipients — useful preview before launch
|
||||
// so the user knows which template will actually be used and how many.
|
||||
const langBreakdown = computed(() => {
|
||||
|
|
|
|||
|
|
@ -87,6 +87,134 @@ const LOWER_WORDS = new Set(['de','du','des','la','le','les','au','aux','à',
|
|||
'et','sur','en'])
|
||||
const EMAIL_SPLIT = /\s*[;,]\s*/
|
||||
|
||||
// ── French Québécois first-name accent restoration ──────────────────────────
|
||||
// Top ~100 names from MIQ Service Canada Québec baby names + manual additions
|
||||
// for older generations (legacy customer base skews 40+). Lookup is on the
|
||||
// LOWERCASE no-accent form → returns the properly accented version.
|
||||
// Missing from this list: rare names, English names that don't need fixing,
|
||||
// already-correctly-accented names (handled by Title Case alone).
|
||||
const FR_NAME_FIXES = {
|
||||
// É (acute)
|
||||
'andre': 'André', 'andree': 'Andrée', 'aimee': 'Aimée',
|
||||
'amelie': 'Amélie', 'angele': 'Angèle', 'audrey': 'Audrey',
|
||||
'beatrice': 'Béatrice', 'benedict': 'Bénédict',
|
||||
'cecile': 'Cécile', 'cedric': 'Cédric', 'celine': 'Céline', 'chloe': 'Chloé',
|
||||
'clemence': 'Clémence', 'clement': 'Clément',
|
||||
'desire': 'Désiré', 'desiree': 'Désirée',
|
||||
'edith': 'Édith', 'edouard': 'Édouard', 'eliane': 'Éliane',
|
||||
'elie': 'Élie', 'elisa': 'Élisa', 'elise': 'Élise',
|
||||
'eloi': 'Éloi', 'emile': 'Émile', 'emilie': 'Émilie',
|
||||
'emmanuel': 'Emmanuel', 'eric': 'Éric', 'ethan': 'Éthan', 'eugene': 'Eugène',
|
||||
'felix': 'Félix', 'fleur': 'Fleur', 'francois': 'François', 'francoise': 'Françoise',
|
||||
'frederic': 'Frédéric', 'frederique': 'Frédérique',
|
||||
'gabrielle': 'Gabrielle', 'gaetan': 'Gaétan', 'gaetane': 'Gaétane',
|
||||
'genevieve': 'Geneviève', 'gerald': 'Gérald', 'gerard': 'Gérard',
|
||||
'helena': 'Helena', 'helene': 'Hélène', 'herve': 'Hervé',
|
||||
'irene': 'Irène', 'jeremie': 'Jérémie', 'jeremy': 'Jérémie',
|
||||
'jerome': 'Jérôme', 'jose': 'José', 'josee': 'Josée',
|
||||
'leandre': 'Léandre', 'leon': 'Léon', 'leo': 'Léo', 'leonard': 'Léonard',
|
||||
'magali': 'Magali', 'medard': 'Médard',
|
||||
'melanie': 'Mélanie', 'michele': 'Michèle',
|
||||
'noemie': 'Noémie', 'noel': 'Noël', 'noella': 'Noëlla',
|
||||
'pamela': 'Paméla', 'pierre': 'Pierre',
|
||||
'raphael': 'Raphaël', 'raphaele': 'Raphaëlle', 'regina': 'Régina',
|
||||
'regine': 'Régine', 'rejean': 'Réjean', 'rejeanne': 'Réjeanne',
|
||||
'remi': 'Rémi', 'rene': 'René', 'renee': 'Renée',
|
||||
'salome': 'Salomé', 'sebastien': 'Sébastien', 'severin': 'Séverin',
|
||||
'severine': 'Séverine', 'solange': 'Solange',
|
||||
'stephane': 'Stéphane', 'stephanie': 'Stéphanie',
|
||||
'theo': 'Théo', 'theodore': 'Théodore', 'therese': 'Thérèse',
|
||||
'valerie': 'Valérie', 'veronique': 'Véronique', 'zoe': 'Zoé',
|
||||
}
|
||||
|
||||
// Parts known to form compound first names in QC (Marie-André, Jean-Philippe,
|
||||
// etc.). When two parts appear concatenated with no separator (e.g. "Marcandre"
|
||||
// or "Mariejose"), we split + hyphenate them. Ordered roughly by frequency so
|
||||
// the longest match wins on lookups.
|
||||
const COMPOUND_PARTS = [
|
||||
'jean', 'marie', 'anne', 'pierre', 'paul', 'louis', 'claude', 'marc',
|
||||
'andre', 'philippe', 'francois', 'francoise', 'jose', 'josee',
|
||||
'luc', 'olivier', 'antoine', 'sebastien', 'michel', 'francois',
|
||||
'christian', 'henri', 'denis', 'rene', 'roger',
|
||||
]
|
||||
|
||||
function splitCompoundName (lower) {
|
||||
for (const a of COMPOUND_PARTS) {
|
||||
if (!lower.startsWith(a)) continue
|
||||
const rest = lower.slice(a.length)
|
||||
if (!rest) continue
|
||||
if (COMPOUND_PARTS.includes(rest)) {
|
||||
return [a, rest]
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Title-case a single token. Handles apostrophes (O'Brien, L'Heureux) and
|
||||
// hyphens (Marie-André). Returns input unchanged if empty.
|
||||
function titleCaseToken (tok) {
|
||||
if (!tok) return tok
|
||||
return tok.replace(/(^|[\s\-'])([\p{L}])/gu, (_, sep, ch) => sep + ch.toUpperCase())
|
||||
.replace(/(?<=[\p{L}])([\p{L}]+)/gu, m => m.toLowerCase())
|
||||
}
|
||||
|
||||
// Clean a person name: trim, Title Case, accent-restore from dictionary,
|
||||
// split known compound concatenations. Returns the cleaned name.
|
||||
function cleanName (raw) {
|
||||
if (!raw) return ''
|
||||
let s = raw.trim()
|
||||
if (!s) return ''
|
||||
// First pass: Title Case (so 'MARC' → 'Marc', 'marc' → 'Marc')
|
||||
s = s.split(/\s+/).map(titleCaseToken).join(' ')
|
||||
|
||||
// Apply dictionary fixes to each space-separated word
|
||||
s = s.split(' ').map(word => {
|
||||
// For hyphenated compounds, try each part separately
|
||||
if (word.includes('-')) {
|
||||
return word.split('-').map(p => {
|
||||
const fix = FR_NAME_FIXES[p.toLowerCase()]
|
||||
return fix || titleCaseToken(p)
|
||||
}).join('-')
|
||||
}
|
||||
const lower = word.toLowerCase()
|
||||
if (FR_NAME_FIXES[lower]) return FR_NAME_FIXES[lower]
|
||||
// Try compound split: "Marcandre" → "Marc-Andre" → "Marc-André"
|
||||
const parts = splitCompoundName(lower)
|
||||
if (parts) {
|
||||
return parts.map(p => FR_NAME_FIXES[p] || titleCaseToken(p)).join('-')
|
||||
}
|
||||
return word
|
||||
}).join(' ')
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// Heuristics to flag names the cleaner may not have caught — surface to the
|
||||
// user in the preview UI so they can manually verify / edit before sending.
|
||||
// Returns null if the name looks fine, or a short FR string explaining why.
|
||||
function nameWarning (name) {
|
||||
if (!name) return null
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return null
|
||||
if (/\d/.test(trimmed)) return 'contient un chiffre'
|
||||
if (trimmed.length === 1) return 'une seule lettre'
|
||||
if (trimmed.length > 25) return 'trop long, peut-être plusieurs noms collés'
|
||||
// After cleanName, common-name lookups should have hit. If we still see a
|
||||
// name in the unaccented form, it's likely an uncommon name OR a typo.
|
||||
// We can't tell which, so just inform the user.
|
||||
const SUSPECT_PATTERNS = [
|
||||
// Common unaccented forms that the dict should have caught — if they
|
||||
// survive, the name is unusual and worth a glance
|
||||
/^(ana|ari|cleo|elo|jul|leo|maw|oce|pen|sof|the|val)/i,
|
||||
]
|
||||
// Likely two names mashed (no separator, length 13-25, lowercase or with
|
||||
// unexpected capitalisation pattern like 'Marcandre')
|
||||
if (/^[\p{Lu}][\p{Ll}]+[\p{Ll}]{8,}$/u.test(trimmed) && !/[\s\-']/.test(trimmed)) {
|
||||
return 'peut-être deux prénoms collés (vérifier)'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function titleAddress (addr) {
|
||||
if (!addr) return ''
|
||||
return addr.split(/\s+/).map((word, i) => {
|
||||
|
|
@ -165,8 +293,20 @@ function parseMapCsv (text, multi = 'first') {
|
|||
const full = (r['nom au compte'] || r["nom à l'adresse"] || '').trim()
|
||||
if (!full) { skippedNoName++; continue }
|
||||
const parts = full.split(/\s+/, 2)
|
||||
const firstname = parts[0] || ''
|
||||
const lastname = parts.length > 1 ? full.slice(parts[0].length).trim() : ''
|
||||
const firstnameRaw = parts[0] || ''
|
||||
const lastnameRaw = parts.length > 1 ? full.slice(parts[0].length).trim() : ''
|
||||
|
||||
// Auto-clean: Title Case, accent restoration from QC dictionary, compound
|
||||
// name detection. The user sees these cleaned versions in Step 2 and can
|
||||
// edit inline. Original raw values are kept on the recipient (firstname_raw
|
||||
// + lastname_raw) so we know whether the auto-cleaner touched anything.
|
||||
const firstname = cleanName(firstnameRaw)
|
||||
const lastname = cleanName(lastnameRaw)
|
||||
const name_warnings = {
|
||||
firstname: nameWarning(firstname),
|
||||
lastname: nameWarning(lastname),
|
||||
}
|
||||
const cleaned_changed = (firstname !== firstnameRaw || lastname !== lastnameRaw)
|
||||
|
||||
const civic_address = titleAddress(r['adresse dans F'] || '')
|
||||
const postal_code = normalizePostal(r['code postal au compte'] || r["code postal à l'adresse"] || '')
|
||||
|
|
@ -177,6 +317,9 @@ function parseMapCsv (text, multi = 'first') {
|
|||
seen.add(em)
|
||||
contacts.push({
|
||||
firstname, lastname,
|
||||
firstname_raw: firstnameRaw, lastname_raw: lastnameRaw,
|
||||
cleaned_changed,
|
||||
name_warnings,
|
||||
email: em,
|
||||
phone,
|
||||
civic_address,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user