Dans le projet ActuAsso, j'ai mis en place un système d'import de membres à partir d'un fichier Excel. Le besoin paraît simple au départ : un responsable d'association possède une liste de personnes, avec un nom, une adresse email et parfois un numéro de mobile, et il veut les intégrer dans l'application.
Mais je ne voulais pas faire un import brutal directement dans la table des membres. Une liste Excel contient des données personnelles, et le fait qu'un responsable les possède ne suffit pas à considérer que chaque personne accepte d'être inscrite dans ActuAsso. J'ai donc construit le flux autour d'un principe simple : j'importe d'abord une intention d'inscription, puis chaque personne confirme ou refuse.
Le principe fonctionnel
Le responsable d'association dépose un fichier .xlsx. ActuAsso lit les lignes, affiche une prévisualisation, permet de corriger ou supprimer des entrées, puis déclenche l'import des personnes sélectionnées.
À ce moment-là, je ne crée pas encore des membres actifs. Je crée des MemberRegistration, c'est-à-dire des inscriptions en attente. Chaque personne reçoit ensuite un email avec un lien personnel vers une page /import-membre/{id}. Sur cette page, elle peut relire ses informations, les modifier, accepter les CGU et la politique de confidentialité, ou refuser.
flowchart TD
A[Responsable association] --> B[Depot fichier Excel]
B --> C[Lecture et validation des lignes]
C --> D[Previsualisation dans ActuAsso]
D --> E[Creation MemberRegistration]
E --> F[Email avec lien personnel]
F --> G{Decision de la personne}
G -->|Accepte| H[Creation du Member actif]
G -->|Refuse| I[Suppression de l'inscription temporaire]Cette séparation est importante : tant que la personne n'a pas accepté, elle n'est pas un membre définitif de la base.
La lecture du fichier Excel
La lecture du fichier est faite côté serveur avec ClosedXML. Le handler GetMemberListFromFileRequestHandler reçoit le contenu du fichier en base64, ouvre le classeur Excel, détecte les colonnes utiles, puis renvoie les lignes au fil de l'eau.
Dans ActuAsso, j'utilise le pattern Mediator via ChannelMediator. C'est lui qui me permet de découpler les contrôleurs, les pages Blazor et la logique métier : le contrôleur envoie une requête, le handler la traite, et le reste de l'application ne dépend pas directement de l'implémentation.
Je ne me base pas sur des positions fixes de colonnes. Le code cherche plutôt des en-têtes comme Prénom, Nom, Email, Mobile, tel ou téléphone. C'est plus tolérant pour les fichiers réels fournis par les associations.
foreach (IXLCell cell in row.CellsUsed())
{
if ($"{cell.Value}".Contains("Prénom", StringComparison.InvariantCultureIgnoreCase)
|| $"{cell.Value}".Contains("Prenom", StringComparison.InvariantCultureIgnoreCase))
{
firstNameColumnIndex = columnIndex;
}
else if ($"{cell.Value}".Contains("Email", StringComparison.InvariantCultureIgnoreCase))
{
emailColumnIndex = columnIndex;
}
}
Le handler implémente un IStreamRequestHandler. Cela me permet de produire les résultats au fur et à mesure plutôt que d'attendre la fin complète du fichier.
internal class GetMemberListFromFileRequestHandler
: IStreamRequestHandler<GetMemberListFromFileRequest, ImportMemberResult>
{
public async IAsyncEnumerable<ImportMemberResult> Handle(
GetMemberListFromFileRequest request,
CancellationToken cancellationToken)
{
// Lecture Excel, validation des colonnes, puis yield ligne par ligne.
yield return memberResult;
}
}
Blazor SSR et JavaScript : afficher la progression sans bloquer l'écran
La page /import-membres est une page Blazor SSR avec [StreamRendering]. Elle affiche une zone de dépôt de fichier et une liste vide qui sera remplie côté navigateur.
@page "/import-membres"
@attribute [StreamRendering]
@attribute [Authorize(Policy = nameof(Policies.CanManageAssociation))]
<div class="upload-members-file">
<input type="file" class="form-control visually-hidden" accept=".xlsx">
<button type="button" class="btn btn-primary file-drop-btn">
Importer un fichier excel
</button>
</div>
<div class="member-list grid column-gap-1"></div>
J'utilise ici JavaScript pour gérer le glisser-déposer, lire le fichier avec FileReader, appeler l'API, puis consommer la réponse HTTP progressivement.
const response = await fetch('/api/members/upload', {
method: 'POST',
signal: signal,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contentType: file.type,
content: pe.target.result,
name: file.name,
size: file.size
})
});
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
La méthode centrale côté serveur est ProcessImportMembers dans MemberController. Elle ne retourne pas un gros JSON final. Elle écrit directement dans Response.Body, avec un Transfer-Encoding: chunked, puis flush chaque morceau de réponse.
[HttpPost]
[Route("/api/members/upload")]
[Authorize(Policy = nameof(Policies.CanManageAssociation))]
public async Task ProcessImportMembers(UploadFile file, CancellationToken cancellationToken)
{
Response.ContentType = "text/plain";
Response.Headers["Cache-Control"] = "no-cache";
Response.Headers["Transfer-Encoding"] = "chunked";
using var writer = new StreamWriter(Response.Body, Encoding.UTF8, leaveOpen: true);
var contentInBase64 = file.Content.Split(';')[1].Split(',')[1];
var request = new GetMemberListFromFileRequest(contentInBase64, file.ContentType);
await foreach (var result in mediator.CreateStream(request))
{
if (result.HasError)
{
continue;
}
var json = JsonSerializer.Serialize(result.Member);
await writer.WriteAsync(json);
await writer.FlushAsync();
}
}
Côté navigateur, je lis chaque chunk, je tente de le parser en JSON, puis j'ajoute une ligne dans la liste. L'utilisateur voit donc les membres apparaître progressivement au lieu d'attendre silencieusement.
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
const item = decoder.decode(value, { stream: true });
if (item.startsWith('{')) {
const member = JSON.parse(item);
appendImportRow(member);
}
}
J'ai aussi prévu l'annulation avec AbortController. Le bouton Annuler appelle controller.abort(), et le serveur vérifie cancellationToken.IsCancellationRequested pendant le traitement.
sequenceDiagram
participant UI as Navigateur
participant API as MemberController
participant Handler as GetMemberListFromFile
participant Excel as Fichier xlsx
UI->>API: POST /api/members/upload
API->>Handler: CreateStream(request)
Handler->>Excel: Lecture ligne par ligne
Handler-->>API: ImportMemberResult
API-->>UI: chunk JSON membre
UI->>UI: Ajout de la ligne dans .member-listL'import ne crée pas encore un membre
Quand le responsable clique sur l'import d'une ligne, le JavaScript appelle /api/member/import. Là encore, le nom de l'endpoint peut laisser croire que le membre est créé immédiatement, mais ce n'est pas le cas. Je crée une MemberRegistration.
[HttpPost]
[Route("/api/member/import")]
[Authorize(Policy = nameof(Policies.CanManageAssociation))]
public async Task<IActionResult> ImportMember(ImportMember importMember)
{
var member = await mediator.Send(new CreateMemberRegistrationRequest());
member.Name = importMember.Name;
member.Email = importMember.Email;
member.MobilePhoneNumber = importMember.MobileNumber;
member.ImportDate = DateTime.Now;
member.StepName = "Start";
member.ImporterId = memberClaimInfo.MemberId;
await mediator.Send(new SaveMemberRegistrationRequest(member));
await mediator.Send(new SendMemberRegistrationImportEmailRequest(member.Id));
return Ok(member.Id);
}
Le modèle MemberRegistration porte les informations temporaires : nom, email, mobile, pseudo, importeur, date d'import, date d'inscription finale, membre créé, et acceptation des conditions.
public class MemberRegistration
{
public Guid Id { get; set; }
public string Name { get; set; } = null!;
public string Email { get; set; } = null!;
public string? MobilePhoneNumber { get; set; }
public Guid? ImporterId { get; set; }
public DateTime? ImportDate { get; set; }
public DateTime? RegisteredDate { get; set; }
public Guid? MemberId { get; set; }
public bool AcceptTerms { get; set; }
}
Le mail de consentement
Après la création de la MemberRegistration, ActuAsso envoie un email basé sur le template memberregistrationimport. Le lien important est construit avec l'identifiant de l'inscription temporaire.
var prms = new Dictionary<string, string>
{
{ "Name", memberRegistration.Name },
{ "AssociationName", association!.Name },
{ "MemberName", importer.Name },
{ "ProfileLink", $"{association.Host}/import-membre/{memberRegistration.Id}" },
{ "AssociationEmail", association.Email }
};
Ce lien est le point d'entrée du consentement. La personne arrive sur une page qui explique qu'un représentant de l'association a pré-rempli ses données, qu'elle peut les modifier, accepter, ou refuser. J'ai volontairement rendu le refus explicite : si la personne refuse, ses informations temporaires sont supprimées.
if (Denied == "denied")
{
await Mediator.Send(new ImportMemberRegistrationRefusedRequest(MemberRegistrationId));
MemberRegistration = new();
return;
}
Et côté handler, le refus supprime réellement l'enregistrement temporaire.
var registration = await db.MemberRegistrations.FindAsync(request.MemberRegistrationId, cancellationToken);
db.MemberRegistrations.Remove(registration);
await db.SaveChangesAsync(cancellationToken);
L'acceptation crée le membre final
Si la personne accepte, je vérifie d'abord qu'elle a bien coché l'acceptation des CGU et de la politique de confidentialité.
if (!MemberRegistration.AcceptTerms)
{
customValidator.AddFieldError(
new FieldIdentifier(MemberRegistration, "AcceptTerms"),
"Tu dois accepter les conditions générales d'utilisation et la politique de confidentialité");
customValidator.DisplayErrors();
return;
}
Ensuite seulement, je crée le membre final à partir de la MemberRegistration.
var saveMemberResult = await Mediator.Send(
new CreateAndSaveMemberFromMemberRegistrationRequest(existing.Id, MemberState.Active.Id));
Le handler CreateAndSaveMemberFromMemberRegistrationRequestHandler reprend les données validées, crée un Member, marque l'email comme confirmé dans ce contexte, ajoute le rôle contributeur, puis renseigne RegisteredDate et MemberId sur l'inscription temporaire.
member.Name = registration.Name;
member.Email = registration.Email;
member.MobilePhoneNumber = registration.MobilePhoneNumber;
member.StateId = request.DefaultStateId;
member.AddRole(MemberRole.Contributor);
registration.MemberId = member.Id;
registration.RegisteredDate = DateTime.Now;
Pourquoi cette architecture me convient
Ce flux me permet de concilier l'ergonomie attendue par les responsables d'association et le respect des données personnelles.
Je peux importer rapidement un fichier Excel, afficher les lignes en progression, corriger les erreurs avant envoi, puis laisser chaque personne décider. En même temps, je garde une trace claire de l'importeur, de la date d'import, de la date d'inscription finale, et je donne une vraie possibilité de refus avec suppression des données temporaires.
Techniquement, le point le plus intéressant est l'association entre Blazor SSR et JavaScript. Blazor SSR me donne une page simple, sécurisée par policy, intégrée au rendu serveur d'ActuAsso. JavaScript prend le relais là où il est le plus efficace : glisser-déposer, FileReader, fetch, lecture de stream, annulation, et mise à jour progressive du DOM.
flowchart LR
subgraph SSR[Blazor SSR]
P[Page /import-membres]
Z[Zone upload]
L[Liste des lignes]
end
subgraph JS[JavaScript]
D[Drag and drop]
F[FileReader]
R[ReadableStream]
U[Mise a jour DOM]
end
subgraph API[API ASP.NET Core]
C[ProcessImportMembers]
I[ImportMember]
end
P --> Z
Z --> D
D --> F
F --> C
C --> R
R --> U
U --> L
L --> IAu final, je n'ai pas seulement ajouté un bouton "Importer". J'ai ajouté un parcours complet : lecture, prévisualisation, progression, invitation, consentement, acceptation ou suppression. C'est exactement le genre de détail qui compte dans ActuAsso, parce que l'application manipule des données de personnes réelles, dans un contexte associatif où la confiance est essentielle.
Aucun commentaire publié pour le moment.
Ajouter un commentaire