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:
AdamThomas
2026-09-15 19:38:28 +02:00
co-authored by Claude Sonnet 5
parent fcc42c8d43
commit 897a76ca6d
11 changed files with 193 additions and 70 deletions
+27 -5
View File
@@ -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';