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
+12 -7
View File
@@ -65,12 +65,13 @@ les tables sauf les tables d'association.
postes (pas les disponibilités, qui sont des fenêtres libres). postes (pas les disponibilités, qui sont des fenêtres libres).
CRUD `GET/POST/PATCH/DELETE /editions/{id}/day-parts` (COORDINATION pour CRUD `GET/POST/PATCH/DELETE /editions/{id}/day-parts` (COORDINATION pour
l'écriture). l'écriture).
- **`Volunteer`** : `email` (unique **par édition**, nullable pour les - **`Volunteer`** : `email` (nullable, **pas unique** — plusieurs bénévoles
bénévoles créés à la main — plusieurs `NULL` autorisés), `first_name`, peuvent partager une même adresse, ex. un couple ou une famille ; c'est le
`last_name`, `phone`, `comment`. Pas de date de naissance, taille de couple (email, nom + prénom) qui identifie un bénévole, pas l'email seul),
t-shirt, régime, ni compétences. Porte deux listes de vœux, exposées par la `first_name`, `last_name`, `phone`, `comment`. Pas de date de naissance,
fiche (`artist_ids`, `wished_position_ids` ; `None`/absent = inchangé, taille de t-shirt, régime, ni compétences. Porte deux listes de vœux,
`[]` = vider). exposées par la fiche (`artist_ids`, `wished_position_ids` ; `None`/absent =
inchangé, `[]` = vider).
- **`Artist`** : programmation de l'édition (`name`, `performance_date`, - **`Artist`** : programmation de l'édition (`name`, `performance_date`,
`start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le `start_time`, `end_time` — `end_time <= start_time` ⇒ le passage finit le
lendemain). Unique (`edition_id`, `name`, `performance_date`). La lendemain). Unique (`edition_id`, `name`, `performance_date`). La
@@ -308,7 +309,10 @@ défilement horizontal — jamais de colonnes étroites par défaut.
commentaire — pas les disponibilités, qui se déclarent dans l'app), en deux commentaire — pas les disponibilités, qui se déclarent dans l'app), en deux
temps : analyse (colonnes détectées, aperçu) puis exécution avec mapping temps : analyse (colonnes détectées, aperçu) puis exécution avec mapping
validé. Rapport ligne par ligne. Détection des doublons : upsert sur validé. Rapport ligne par ligne. Détection des doublons : upsert sur
`email` ; sans email, détection sur (`last_name`, `first_name`) signalée (`email`, `last_name` + `first_name`) — l'email seul ne suffit pas,
plusieurs bénévoles peuvent le partager (couple, famille) ; un même email
revu avec un nom différent crée un bénévole de plus (signalé, jamais
ignoré) ; sans email, détection sur (`last_name`, `first_name`) signalée
sans upsert automatique. Mode simulation qui ne persiste rien. sans upsert automatique. Mode simulation qui ne persiste rien.
3. API CRUD sur `Position`, `Shift`, `Volunteer`, `Assignment`. 3. API CRUD sur `Position`, `Shift`, `Volunteer`, `Assignment`.
4. Moteur de validation (section 3). 4. Moteur de validation (section 3).
@@ -460,6 +464,7 @@ Aucune dépendance locale requise hors Docker.
| 13 | Bénévole : retrait taille de t-shirt / régime ; entité `Artist` (CRUD, par jour) + vœux des bénévoles (artistes, postes) | à valider | | 13 | Bénévole : retrait taille de t-shirt / régime ; entité `Artist` (CRUD, par jour) + vœux des bénévoles (artistes, postes) | à valider |
| 14 | Retrait des compétences (`Skill` et règle `MISSING_REQUIRED_SKILL`) ; disponibilités = fenêtres horaires libres par jour (`GET`/`PUT` availability), prises en compte par `OUTSIDE_AVAILABILITY` | à valider | | 14 | Retrait des compétences (`Skill` et règle `MISSING_REQUIRED_SKILL`) ; disponibilités = fenêtres horaires libres par jour (`GET`/`PUT` availability), prises en compte par `OUTSIDE_AVAILABILITY` | à valider |
| 15 | Solveur de suggestions OR-Tools CP-SAT (section 4) : `POST /editions/{id}/solver/suggestions`, candidats remontés en tête de liste dans la grille des besoins — aucune affectation automatique | à valider | | 15 | Solveur de suggestions OR-Tools CP-SAT (section 4) : `POST /editions/{id}/solver/suggestions`, candidats remontés en tête de liste dans la grille des besoins — aucune affectation automatique | à valider |
| 16 | Bénévole : l'email n'est plus unique (couple, famille) — identification par (email, nom + prénom) dans l'import et l'API, plus de rejet 409 à la création manuelle | à valider |
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur **Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans + de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
+6 -16
View File
@@ -1,9 +1,11 @@
"""CRUD des bénévoles. La suppression est un effacement complet (RGPD).""" """CRUD des bénévoles. La suppression est un effacement complet (RGPD).
L'email n'est pas unique : plusieurs bénévoles peuvent en partager un même
(couple, famille) — voir `app/models/domain.py:Volunteer`."""
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException, Response, status from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy.exc import IntegrityError
from sqlmodel import Session, select from sqlmodel import Session, select
from app.api._common import owned_or_404, replace_links from app.api._common import owned_or_404, replace_links
@@ -143,13 +145,7 @@ def create_volunteer(
**payload.model_dump(exclude={"artist_ids", "wished_position_ids"}), **payload.model_dump(exclude={"artist_ids", "wished_position_ids"}),
) )
session.add(volunteer) session.add(volunteer)
try: session.flush()
session.flush()
except IntegrityError as exc:
session.rollback()
raise HTTPException(
status_code=409, detail={"code": "volunteer.duplicate_email"}
) from exc
_set_artists(session, volunteer, payload.artist_ids) _set_artists(session, volunteer, payload.artist_ids)
_set_wished_positions(session, volunteer, payload.wished_position_ids) _set_wished_positions(session, volunteer, payload.wished_position_ids)
session.commit() session.commit()
@@ -197,13 +193,7 @@ def update_volunteer(
wished_position_ids = changes.pop("wished_position_ids", None) wished_position_ids = changes.pop("wished_position_ids", None)
for field, value in changes.items(): for field, value in changes.items():
setattr(volunteer, field, value) setattr(volunteer, field, value)
try: session.flush()
session.flush()
except IntegrityError as exc:
session.rollback()
raise HTTPException(
status_code=409, detail={"code": "volunteer.duplicate_email"}
) from exc
if artist_ids is not None: if artist_ids is not None:
_set_artists(session, volunteer, artist_ids) _set_artists(session, volunteer, artist_ids)
if wished_position_ids is not None: if wished_position_ids is not None:
+2 -2
View File
@@ -10,7 +10,6 @@
"day_part.duplicate_code": "Un créneau type porte déjà ce code dans l'édition.", "day_part.duplicate_code": "Un créneau type porte déjà ce code dans l'édition.",
"shift.ends_not_after_starts": "La fin du créneau doit être postérieure à son début.", "shift.ends_not_after_starts": "La fin du créneau doit être postérieure à son début.",
"shift.position_wrong_edition": "Le poste n'appartient pas à cette édition.", "shift.position_wrong_edition": "Le poste n'appartient pas à cette édition.",
"volunteer.duplicate_email": "Un bénévole avec cet email existe déjà dans l'édition.",
"artist.name_required": "Le nom de l'artiste ne peut pas être vide.", "artist.name_required": "Le nom de l'artiste ne peut pas être vide.",
"artist.duplicate": "Un artiste porte déjà ce nom à cette date dans l'édition.", "artist.duplicate": "Un artiste porte déjà ce nom à cette date dans l'édition.",
"artist.unknown": "Un des artistes choisis n'appartient pas à cette édition.", "artist.unknown": "Un des artistes choisis n'appartient pas à cette édition.",
@@ -33,7 +32,8 @@
"import.mapping.column_not_found": "La colonne « {column} » est absente du fichier.", "import.mapping.column_not_found": "La colonne « {column} » est absente du fichier.",
"import.row.missing_required_field": "Champ obligatoire manquant : {field}.", "import.row.missing_required_field": "Champ obligatoire manquant : {field}.",
"import.row.invalid_email": "Adresse email invalide : {value}.", "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.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.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.", "import.row.possible_duplicate_name": "Un bénévole portant le même nom existe déjà dans l'édition.",
+40 -12
View File
@@ -2,6 +2,12 @@
L'import ne porte que l'identité des bénévoles (nom, email, téléphone, L'import ne porte que l'identité des bénévoles (nom, email, téléphone,
commentaire). Les disponibilités se déclarent dans l'application. commentaire). Les disponibilités se déclarent dans l'application.
Détection des doublons : upsert sur (email, nom + prénom) — l'email seul ne
suffit pas, plusieurs bénévoles peuvent le partager (couple, famille) ; sans
email, détection sur (nom, prénom) signalée sans upsert automatique. Un même
email revu avec un nom différent crée un bénévole de plus (signalé), jamais
ignoré.
""" """
import re import re
@@ -104,14 +110,20 @@ def run_import(
select(Volunteer).where(Volunteer.edition_id == edition.id) select(Volunteer).where(Volunteer.edition_id == edition.id)
) )
) )
by_email = {v.email.lower(): v for v in existing if v.email} # Plusieurs bénévoles peuvent partager un même email (couple, famille) :
# l'email seul ne suffit pas à identifier un bénévole existant, il faut
# l'associer au nom. `by_name` ne sert qu'aux bénévoles sans email.
by_email_and_name: dict[tuple[str, tuple[str, str]], Volunteer] = {}
by_name: dict[tuple[str, str], Volunteer] = {} by_name: dict[tuple[str, str], Volunteer] = {}
for volunteer in existing: for volunteer in existing:
if not volunteer.email: key = (normalize(volunteer.last_name), normalize(volunteer.first_name))
key = (normalize(volunteer.last_name), normalize(volunteer.first_name)) if volunteer.email:
by_email_and_name[(volunteer.email.lower(), key)] = volunteer
else:
by_name.setdefault(key, volunteer) by_name.setdefault(key, volunteer)
report = ImportReport(dry_run=mapping.dry_run, total_rows=len(table.rows)) report = ImportReport(dry_run=mapping.dry_run, total_rows=len(table.rows))
row_seen: dict[tuple[str, tuple[str, str]], int] = {}
email_seen: dict[str, int] = {} email_seen: dict[str, int] = {}
name_seen: dict[tuple[str, str], int] = {} name_seen: dict[tuple[str, str], int] = {}
@@ -151,34 +163,50 @@ def run_import(
report.skipped += 1 report.skipped += 1
continue continue
name_key = (normalize(last_name), normalize(first_name))
if email: if email:
if email in email_seen: row_key = (email, name_key)
if row_key in row_seen:
# Même email ET même nom déjà vus : la ligne est répétée dans
# le fichier (copier-coller), pas deux bénévoles distincts.
entry.issues.append( entry.issues.append(
Issue( Issue(
code="import.row.duplicate_email_in_file", code="import.row.duplicate_email_in_file",
severity="ERROR", severity="ERROR",
field="email", field="email",
params={"value": email_raw, "first_line": email_seen[email]}, params={"value": email_raw, "first_line": row_seen[row_key]},
) )
) )
report.rows.append(entry) report.rows.append(entry)
report.skipped += 1 report.skipped += 1
continue continue
email_seen[email] = line if email in email_seen:
# Même email, nom différent : deux bénévoles peuvent partager
# une adresse (couple, famille) — on les crée tous les deux,
# simplement signalé pour la coordination.
entry.issues.append(
Issue(
code="import.row.shared_email",
severity="WARNING",
field="email",
params={"value": email_raw, "first_line": email_seen[email]},
)
)
row_seen[row_key] = line
email_seen.setdefault(email, line)
else: else:
key = (normalize(last_name), normalize(first_name)) if name_key in name_seen:
if key in name_seen:
entry.issues.append( entry.issues.append(
Issue( Issue(
code="import.row.duplicate_name_in_file", code="import.row.duplicate_name_in_file",
severity="WARNING", severity="WARNING",
params={"first_line": name_seen[key]}, params={"first_line": name_seen[name_key]},
) )
) )
else: else:
name_seen[key] = line name_seen[name_key] = line
volunteer = by_email.get(email) if email else None volunteer = by_email_and_name.get((email, name_key)) if email else None
if volunteer is not None: if volunteer is not None:
entry.action = "UPDATED" entry.action = "UPDATED"
entry.issues.append( entry.issues.append(
@@ -190,7 +218,7 @@ def run_import(
) )
else: else:
if not email: if not email:
match = by_name.get((normalize(last_name), normalize(first_name))) match = by_name.get(name_key)
if match is not None: if match is not None:
entry.issues.append( entry.issues.append(
Issue( Issue(
@@ -0,0 +1,32 @@
"""Bénévole : l'email n'est plus unique par édition
Revision ID: 0009_volunteer_email_not_unique
Revises: 0008_artist_performance_time
Create Date: 2026-09-15
Plusieurs bénévoles peuvent partager une même adresse (couple, famille) :
l'import les traitait auparavant comme des doublons et ignorait le second.
L'unicité (edition_id, email) est retirée ; l'index sur `email` seul est
conservé pour les recherches.
"""
from collections.abc import Sequence
from alembic import op
revision: str = "0009_volunteer_email_not_unique"
down_revision: str | None = "0008_artist_performance_time"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.drop_constraint(
"uq_volunteer_edition_email", "volunteer", type_="unique"
)
def downgrade() -> None:
op.create_unique_constraint(
"uq_volunteer_edition_email", "volunteer", ["edition_id", "email"]
)
+4 -8
View File
@@ -133,18 +133,14 @@ class Artist(TimestampMixin, table=True):
class Volunteer(TimestampMixin, table=True): class Volunteer(TimestampMixin, table=True):
__table_args__ = (
UniqueConstraint(
"edition_id", "email", name="uq_volunteer_edition_email"
),
)
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
edition_id: uuid.UUID = Field( edition_id: uuid.UUID = Field(
foreign_key="edition.id", index=True, ondelete="CASCADE" foreign_key="edition.id", index=True, ondelete="CASCADE"
) )
# Identifiant fonctionnel unique par édition. Nullable : bénévole créé à la # Pas unique : plusieurs bénévoles peuvent partager une même adresse (ex.
# main par la coordination, sans email. # un couple, une famille). L'import (app/importing/service.py) distingue
# deux bénévoles d'un même email par leur nom ; la coordination reste
# seule juge en cas d'ambiguïté réelle.
email: str | None = Field(default=None, index=True) email: str | None = Field(default=None, index=True)
first_name: str first_name: str
last_name: str last_name: str
+7 -5
View File
@@ -34,12 +34,14 @@ def test_create_volunteer(client, edition):
assert body["wished_position_ids"] == [] assert body["wished_position_ids"] == []
def test_create_duplicate_email_conflicts(client, edition): def test_several_volunteers_can_share_an_email(client, edition):
# Un couple, une famille : même boîte mail, deux bénévoles distincts.
payload = {"first_name": "A", "last_name": "A", "email": "dup@ex.fr"} payload = {"first_name": "A", "last_name": "A", "email": "dup@ex.fr"}
assert client.post(_url(edition), json=payload).status_code == 201 first = client.post(_url(edition), json=payload)
resp = client.post(_url(edition), json={**payload, "first_name": "B"}) assert first.status_code == 201
assert resp.status_code == 409 second = client.post(_url(edition), json={**payload, "first_name": "B"})
assert resp.json()["detail"]["code"] == "volunteer.duplicate_email" assert second.status_code == 201
assert first.json()["id"] != second.json()["id"]
def test_volunteers_without_email_are_allowed(client, edition): def test_volunteers_without_email_are_allowed(client, edition):
+57 -8
View File
@@ -75,13 +75,13 @@ def test_dry_run_persists_nothing(session, edition):
assert _volunteers(session, edition) == [] assert _volunteers(session, edition) == []
def test_existing_email_is_updated_not_duplicated(session, edition): def test_existing_email_and_name_is_updated_not_duplicated(session, edition):
session.add( session.add(
Volunteer( Volunteer(
edition_id=edition.id, edition_id=edition.id,
email="alex@ex.fr", email="alex@ex.fr",
first_name="Alexandre", first_name="Alex",
last_name="M.", last_name="Martin",
phone="0600000000", phone="0600000000",
) )
) )
@@ -90,7 +90,7 @@ def test_existing_email_is_updated_not_duplicated(session, edition):
report = run_import( report = run_import(
session=session, session=session,
edition=edition, edition=edition,
table=_table([["Alex", "Martin", "ALEX@ex.fr", "", ""]]), table=_table([["alex", "MARTIN", "ALEX@ex.fr", "0611111111", ""]]),
mapping=_mapping(), mapping=_mapping(),
) )
@@ -98,18 +98,46 @@ def test_existing_email_is_updated_not_duplicated(session, edition):
assert report.rows[0].issues[0].code == "import.row.updated_existing" assert report.rows[0].issues[0].code == "import.row.updated_existing"
volunteers = _volunteers(session, edition) volunteers = _volunteers(session, edition)
assert len(volunteers) == 1 assert len(volunteers) == 1
assert volunteers[0].first_name == "Alex" assert volunteers[0].phone == "0611111111"
assert volunteers[0].last_name == "Martin"
def test_duplicate_email_within_file_is_skipped(session, edition): def test_same_email_different_name_creates_a_second_volunteer(session, edition):
# Un même email peut être partagé (couple, famille) : il ne suffit plus à
# lui seul à identifier « le » bénévole, il faut aussi le nom — sinon un
# second bénévole avec la même adresse était perdu (silencieusement fondu
# dans le premier, ou ignoré selon le cas).
session.add(
Volunteer(
edition_id=edition.id,
email="famille@ex.fr",
first_name="Alex",
last_name="Martin",
)
)
session.commit()
report = run_import(
session=session,
edition=edition,
table=_table([["Sam", "Martin", "famille@ex.fr", "", ""]]),
mapping=_mapping(),
)
assert (report.created, report.updated, report.skipped) == (1, 0, 0)
assert report.rows[0].action == "CREATED"
names = {(v.first_name, v.last_name) for v in _volunteers(session, edition)}
assert names == {("Alex", "Martin"), ("Sam", "Martin")}
def test_duplicate_row_within_file_is_skipped(session, edition):
# Même nom ET même email répétés : une ligne recopiée, pas deux bénévoles.
report = run_import( report = run_import(
session=session, session=session,
edition=edition, edition=edition,
table=_table( table=_table(
[ [
["Alex", "Martin", "dup@ex.fr", "", ""], ["Alex", "Martin", "dup@ex.fr", "", ""],
["Alex", "Doublon", "dup@ex.fr", "", ""], ["alex", "MARTIN", "DUP@ex.fr", "", ""],
] ]
), ),
mapping=_mapping(), mapping=_mapping(),
@@ -121,6 +149,27 @@ def test_duplicate_email_within_file_is_skipped(session, edition):
assert skipped.issues[0].params["first_line"] == 2 assert skipped.issues[0].params["first_line"] == 2
def test_shared_email_within_file_creates_both_with_a_warning(session, edition):
report = run_import(
session=session,
edition=edition,
table=_table(
[
["Alex", "Martin", "famille@ex.fr", "", ""],
["Sam", "Martin", "famille@ex.fr", "", ""],
]
),
mapping=_mapping(),
)
assert (report.created, report.skipped) == (2, 0)
second = report.rows[1]
assert second.action == "CREATED"
assert second.issues[0].code == "import.row.shared_email"
assert second.issues[0].params["first_line"] == 2
names = {(v.first_name, v.last_name) for v in _volunteers(session, edition)}
assert names == {("Alex", "Martin"), ("Sam", "Martin")}
def test_row_without_email_matching_existing_name_warns_but_creates( def test_row_without_email_matching_existing_name_warns_but_creates(
session, edition session, edition
): ):
+4 -5
View File
@@ -1,8 +1,6 @@
import datetime as dt import datetime as dt
import pytest
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
from app.models import ( from app.models import (
Assignment, Assignment,
@@ -121,7 +119,9 @@ def test_availability_window_round_trip(session):
assert reloaded.end_time == dt.time(2, 0) assert reloaded.end_time == dt.time(2, 0)
def test_volunteer_email_unique_per_edition(session): def test_volunteer_email_can_be_shared(session):
# Un couple, une famille : même boîte mail, deux bénévoles distincts —
# ce n'est plus une contrainte d'unicité en base (voir migration 0009).
edition = _edition(session) edition = _edition(session)
session.add( session.add(
Volunteer( Volunteer(
@@ -140,8 +140,7 @@ def test_volunteer_email_unique_per_edition(session):
last_name="B", last_name="B",
) )
) )
with pytest.raises(IntegrityError): session.commit() # ne lève pas
session.commit()
def test_multiple_volunteers_without_email_allowed(session): def test_multiple_volunteers_without_email_allowed(session):
+27 -5
View File
@@ -810,6 +810,7 @@ export function createMockApi(): PlanningApi {
skipped: 0, skipped: 0,
rows: [], rows: [],
}; };
const rowSeen = new Map<string, number>(); // clé `${email}|${nom normalisé}`
const emailSeen = new Map<string, number>(); const emailSeen = new Map<string, number>();
// On travaille sur des copies : `state` n'est touché qu'en fin si !dry_run. // On travaille sur des copies : `state` n'est touché qu'en fin si !dry_run.
const working = state.volunteers.map((v) => ({ ...v })); const working = state.volunteers.map((v) => ({ ...v }));
@@ -849,24 +850,45 @@ export function createMockApi(): PlanningApi {
report.skipped += 1; report.skipped += 1;
return; return;
} }
const nameKey = `${normalizeText(last)}|${normalizeText(first)}`;
if (email) { if (email) {
const seenAt = emailSeen.get(email); const rowKey = `${email}|${nameKey}`;
if (seenAt != null) { 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({ entry.issues.push({
code: 'import.row.duplicate_email_in_file', code: 'import.row.duplicate_email_in_file',
severity: 'ERROR', severity: 'ERROR',
field: 'email', field: 'email',
params: { value: emailRaw, first_line: seenAt }, params: { value: emailRaw, first_line: rowSeenAt },
}); });
report.rows.push(entry); report.rows.push(entry);
report.skipped += 1; report.skipped += 1;
return; 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 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; : undefined;
if (vol) { if (vol) {
entry.action = 'UPDATED'; entry.action = 'UPDATED';
+2 -2
View File
@@ -13,7 +13,6 @@
"error.validation_failed": "La validation a échoué (règle bloquante)", "error.validation_failed": "La validation a échoué (règle bloquante)",
"assignment.duplicate": "Ce bénévole est déjà affecté à ce créneau", "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", "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.ends_not_after_starts": "La fin doit être après le début",
"shift.position_wrong_edition": "Le poste n'appartient pas à cette édition", "shift.position_wrong_edition": "Le poste n'appartient pas à cette édition",
"auth.invalid_credentials": "Email ou mot de passe incorrect", "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.mapping.column_not_found": "La colonne « {column} » est absente du fichier.",
"import.row.missing_required_field": "Champ obligatoire manquant : {field}.", "import.row.missing_required_field": "Champ obligatoire manquant : {field}.",
"import.row.invalid_email": "Adresse email invalide : {value}.", "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.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.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.", "import.row.possible_duplicate_name": "Un bénévole portant le même nom existe déjà dans l’édition.",