Bénévole : l'email n'est plus unique (couple, famille)
Plusieurs bénévoles peuvent légitimement partager une même adresse email — l'architecture l'interdisait : l'import ignorait silencieusement le second (« duplicate_email_in_file »), et l'ajout manuel renvoyait un 409. Les deux sont corrigés. - Migration 0009 : retrait de la contrainte d'unicité (edition_id, email) sur `volunteer` (l'index sur `email` seul est conservé). - Import (`app/importing/service.py`) : l'upsert se fait désormais sur (email, nom + prénom), pas sur l'email seul. Un même email revu avec un nom différent dans le fichier crée un second bénévole (nouveau code `import.row.shared_email`, WARNING) au lieu d'être ignoré ; `duplicate_email_in_file` ne se déclenche plus que sur une ligne littéralement répétée (même email ET même nom). - API (`POST`/`PATCH /volunteers`) : retrait du rejet 409 `volunteer.duplicate_email`, devenu sans objet (le mock ne l'a d'ailleurs jamais appliqué à la création manuelle). - Mock frontend (`mock-client.ts`) : même correction de l'upsert par import, pour rester cohérent avec le backend. - CLAUDE.md à jour (modèle de domaine, règles d'import, journal). Contrepartie assumée (documentée) : un email re-importé avec un nom légèrement différent (faute de frappe corrigée) crée désormais un bénévole de plus signalé, plutôt que de renommer silencieusement l'existant — même philosophie que la détection sans email, qui ne fusionne jamais rien automatiquement. Vérifié : 179 tests backend, ruff, alembic check ; typecheck/lint/build/smoke frontend ; reseed de démonstration. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
fcc42c8d43
commit
897a76ca6d
@@ -810,6 +810,7 @@ export function createMockApi(): PlanningApi {
|
||||
skipped: 0,
|
||||
rows: [],
|
||||
};
|
||||
const rowSeen = new Map<string, number>(); // clé `${email}|${nom normalisé}`
|
||||
const emailSeen = new Map<string, number>();
|
||||
// On travaille sur des copies : `state` n'est touché qu'en fin si !dry_run.
|
||||
const working = state.volunteers.map((v) => ({ ...v }));
|
||||
@@ -849,24 +850,45 @@ export function createMockApi(): PlanningApi {
|
||||
report.skipped += 1;
|
||||
return;
|
||||
}
|
||||
const nameKey = `${normalizeText(last)}|${normalizeText(first)}`;
|
||||
if (email) {
|
||||
const seenAt = emailSeen.get(email);
|
||||
if (seenAt != null) {
|
||||
const rowKey = `${email}|${nameKey}`;
|
||||
const rowSeenAt = rowSeen.get(rowKey);
|
||||
if (rowSeenAt != null) {
|
||||
// Même email ET même nom déjà vus : ligne répétée dans le
|
||||
// fichier (copier-coller), pas deux bénévoles distincts.
|
||||
entry.issues.push({
|
||||
code: 'import.row.duplicate_email_in_file',
|
||||
severity: 'ERROR',
|
||||
field: 'email',
|
||||
params: { value: emailRaw, first_line: seenAt },
|
||||
params: { value: emailRaw, first_line: rowSeenAt },
|
||||
});
|
||||
report.rows.push(entry);
|
||||
report.skipped += 1;
|
||||
return;
|
||||
}
|
||||
emailSeen.set(email, line);
|
||||
const emailSeenAt = emailSeen.get(email);
|
||||
if (emailSeenAt != null) {
|
||||
// Même email, nom différent : deux bénévoles peuvent partager
|
||||
// une adresse (couple, famille) — on les crée tous les deux.
|
||||
entry.issues.push({
|
||||
code: 'import.row.shared_email',
|
||||
severity: 'WARNING',
|
||||
field: 'email',
|
||||
params: { value: emailRaw, first_line: emailSeenAt },
|
||||
});
|
||||
}
|
||||
rowSeen.set(rowKey, line);
|
||||
if (emailSeenAt == null) emailSeen.set(email, line);
|
||||
}
|
||||
|
||||
let vol = email
|
||||
? working.find((v) => (v.email ?? '').toLowerCase() === email)
|
||||
? working.find(
|
||||
(v) =>
|
||||
(v.email ?? '').toLowerCase() === email &&
|
||||
normalizeText(v.last_name) === normalizeText(last) &&
|
||||
normalizeText(v.first_name) === normalizeText(first),
|
||||
)
|
||||
: undefined;
|
||||
if (vol) {
|
||||
entry.action = 'UPDATED';
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
"error.validation_failed": "La validation a échoué (règle bloquante)",
|
||||
"assignment.duplicate": "Ce bénévole est déjà affecté à ce créneau",
|
||||
"assignment.wrong_edition": "Bénévole ou créneau hors de cette édition",
|
||||
"volunteer.duplicate_email": "Un bénévole avec cet email existe déjà",
|
||||
"shift.ends_not_after_starts": "La fin doit être après le début",
|
||||
"shift.position_wrong_edition": "Le poste n'appartient pas à cette édition",
|
||||
"auth.invalid_credentials": "Email ou mot de passe incorrect",
|
||||
@@ -264,7 +263,8 @@
|
||||
"import.mapping.column_not_found": "La colonne « {column} » est absente du fichier.",
|
||||
"import.row.missing_required_field": "Champ obligatoire manquant : {field}.",
|
||||
"import.row.invalid_email": "Adresse email invalide : {value}.",
|
||||
"import.row.duplicate_email_in_file": "Email déjà présent à la ligne {first_line} du fichier.",
|
||||
"import.row.duplicate_email_in_file": "Même bénévole (nom et email) déjà présent à la ligne {first_line} du fichier.",
|
||||
"import.row.shared_email": "Email déjà utilisé par un autre bénévole à la ligne {first_line} du fichier.",
|
||||
"import.row.duplicate_name_in_file": "Nom et prénom déjà présents à la ligne {first_line} du fichier.",
|
||||
"import.row.updated_existing": "Bénévole existant mis à jour.",
|
||||
"import.row.possible_duplicate_name": "Un bénévole portant le même nom existe déjà dans l’édition.",
|
||||
|
||||
Reference in New Issue
Block a user