Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(upload): User list upload refactor #2559

Open
wants to merge 22 commits into
base: staging
Choose a base branch
from

Conversation

aminedhobb
Copy link
Collaborator

@aminedhobb aminedhobb commented Jan 9, 2025

closes #2447 , #2465 , #2468 , #2469 , #2470 , #2471 , #2472 , #2473

NOTE: pour une review technique vous pouvez passer directement à la partie Implémentation technique.

Contexte

La page permettant d'uploader une liste d'usager pour les créer et les inviter à prendre rdv est la première page qui a été mise en place sur l'application et il s'agit de la fonctionnalité principale de rdv-insertion.
En témoigne le premier commit sur ce repo où la seule page concernait cette fonctionnalité: 8894f93#diff-f39f53ad607869e40f515797c75db57d7c819396d221f274188db94bb780a061

Expérimentation Ardennes (Début 2021)

En effet l'application rdv-insertion a d'abord été créée suite à une expérimentation dans les Ardennes. On avait créé une page dans laquelle un agent des Ardennes pouvait uploader un fichier excel, pour pouvoir créer des comptes et générer des tokens d'invitations.
Il pouvait alors télécharger un fichier résultat où pour chaque ligne d'usager on avait inséré une url avec son token pour que l'usager crée son compte sur rdv-sp avec ses infos pré-remplies. L'usager devait alors choisir un mot de passe puis lui-même chercher un rdv en rentrant son adresse sur rdv-sp (bien loin du fonctionnement d'aujourd'hui ^^) !

Le fichier généré avec les liens d'invitations servait de base à un publipostage où on envoyait un courrier à l'usager pour qu'il crée son compte.
Le code de cette application est toujours disponible et faisait partie de l'outil d'analyse des flux insertion créé par Thomas Guillet sur ce repo: https://github.com/betagouv/analyse-flux-insertion (app Next.js)

Début rdv-insertion (juillet 2021)

La première page et feature de rdv-insertion était donc cette même page, avec plusieurs additions: possibilité d'afficher qu'un usager a déjà été créé et invité au ré-upload du fichier, et surtout possibilité d'envoyer un sms avec cette fois un lien de prise de rdv.
Cette page a bien évidemment beaucoup été étoffée au fil du temps mais reste la fonctionnalité principale de l'application et celle que l'on montre en premier en démo.

Besoin de changement

Du fait de l'ancienneté de cette feature, beaucoup de choses ont été rajoutée sur cette page au fil du temps: possibilité d'envoyer des mails, de générer des courriers, d'assigner un référent, de faire les actions en masse etc.

Problèmes d'UX

Ces features ont été ajoutées les unes après les autres sur ce qui a déjà été fait et a eu pour conséquence d'alourdir la lisibilité des actions possibles sur cette page.
Comme il s'agit de la fonctionnalité que l'on met le plus en avant sur l'app, il était nécessaire de faire une refonte complète pour une UX beaucoup plus agréable pour nos utilisateurs.
De plus cette fonctionnalité a été développée avant l'arrivée d'une UX designeuse dans notre équipe, il était donc important pour nous de la repenser avec notre experte design.

Problèmes techniques

Comme expliqué plus haut les fondements de cette page ont d'abord été développés en React au sein d'une appli Next.js.
On est donc parti de ce code pour lancer l'application rdv-insertion, en ajoutant une base de données pour sauvegarder le fait qu'une personne ait déjà été invitée.
Nous avons donc créé une appli Rails en gardant le code front de cette page en React et cela a plusieurs inconvénients:

  • Ça nous rajoute une dépendance à React et à toutes les librairies qu'on utilise avec (mobX etc.)
  • Ça rend le code + verbeux. On doit notamment recoder certaines logiques métier en JS
  • Le code est de moins en moins lisible (possiblement accentué par une expertise + faible en moyenne sur cette techno au sein de l'équipe)

Au vu de ce qu'il est possible de faire aujourd'hui en terme d'UX dynamique sans avoir à ajouter de librairie JS (via Hotwire pour rails), rien ne justifie de garder cette librairie.
Au contraire, le fait d'enlever la partie react et tout faire via Rails a plusieurs avantages:

  • clarté du code en le rendant moins verbeux
  • plus simple de garder tout le state côté serveur et gérer l'affichage via du code ruby
  • logique métier non dupliquée
  • Métriques poussées en sauvegardant en db les interactions (fichiers, tentatives de création d'usager et d'invitations) qui pourront être utile pour de l'analyse de données ou du debug

Travail UX

Avant de passer à l'explication technique je tenais à saluer le gros travail de fond de notre designeuse Samantha qui a imaginé à quoi ressemblerait la version idéale de l'upload.

Cette refonte n'aurait jamais pu avoir le jour sans ce gros travail 🙏 !

Les maquettes sont disponibles ici et la feature a été développé dans l'idée d'être le plus fidèle possible à ces maquettes.

Vidéo démonstrative 🎥

Charger.un.fichier.usagers.-.CD.de.DIE.-.rdv-insertion.1.webm

Implémentation technique

Diagramme de séquence

Ci-dessous le diagramme de séquence qui résume tout le flow du début à la fin du nouveau parcours d'upload:

sequenceDiagram
    actor Agent
    participant CategorySelectionsController
    participant UserListUploadsController
    participant UserRowCellsController
    participant UserRowsController
    participant UserSaveAttemptsController
    participant InvitationAttemptsController

    Note over Agent: Start Upload Process

    Agent->>CategorySelectionsController: GET /user_list_uploads/category_selections/new
    CategorySelectionsController-->>Agent: Display category selection page

    Agent->>UserListUploadsController: GET /user_list_uploads/new?category_configuration_id=X
    UserListUploadsController-->>Agent: Display file upload form

    Note over Agent: Client parses CSV/XLS file
    Agent->>UserListUploadsController: POST /user_list_uploads (with user_rows_attributes)
    UserListUploadsController-->>Agent: Redirect to show page

    Agent->>UserListUploadsController: GET /user_list_uploads/:id
    UserListUploadsController-->>Agent: Display user rows

    Note over Agent: User Rows Management

    alt Search Users
        Agent->>UserListUploadsController: GET /user_list_uploads/:id?search_query=X
        UserListUploadsController-->>Agent: Display filtered results
    end

    alt Sort Users
        Agent->>UserListUploadsController: GET /user_list_uploads/:id?sort_by=X&sort_direction=Y
        UserListUploadsController-->>Agent: Display sorted results
    end

    alt Edit User Row
        Agent->>UserRowCellsController: GET /user_list_uploads/:user_list_upload_id/user_rows/:id/user_row_cells/edit
        UserRowCellsController-->>Agent: Return edit form (Turbo Stream)
        Agent->>UserRowsController: PATCH /user_list_uploads/:user_list_upload_id/user_rows/:id
        UserRowsController-->>Agent: Update cell content (Turbo Stream)
    end

    alt Toggle Details
        Agent->>UserRowsController: GET /user_list_uploads/:user_list_upload_id/user_rows/:id/show_details
        UserRowsController-->>Agent: Show details (Turbo Stream)
        Agent->>UserRowsController: GET /user_list_uploads/:user_list_upload_id/user_rows/:id/hide_details
        UserRowsController-->>Agent: Hide details (Turbo Stream)
    end

    alt Enrich with CNAF Data
        Note over Agent: Client parses CSV/XLS file
        Agent->>UserListUploadsController: POST /user_list_uploads/:user_list_upload_id/enrich_with_cnaf_data (with rows_cnaf_data)
        UserListUploadsController-->>Agent: Update rows with CNAF data
    end

    Note over Agent: Save Users

    Agent->>UserSaveAttemptsController: POST /user_list_uploads/:user_list_upload_id/user_save_attempts/create_many
    Note over UserSaveAttemptsController: Enqueue UserListUpload::SaveUsersJob
    UserSaveAttemptsController-->>Agent: Redirect to save attempts index

    loop Status Check
        Agent->>UserSaveAttemptsController: GET /user_list_uploads/:user_list_upload_id/user_save_attempts
        UserSaveAttemptsController-->>Agent: Display save attempts status
    end

    Note over Agent: Invitation Process

    Agent->>InvitationAttemptsController: GET /user_list_uploads/:user_list_upload_id/invitation_attempts/select_rows
    InvitationAttemptsController-->>Agent: Display row selection for invitations

    Agent->>InvitationAttemptsController: POST /user_list_uploads/:user_list_upload_id/invitation_attempts/create_many
    Note over InvitationAttemptsController: Enqueue UserListUpload::InviteUsersJob
    InvitationAttemptsController-->>Agent: Redirect to invitation attempts index

    loop Status Check
        Agent->>InvitationAttemptsController: GET /user_list_uploads/:user_list_upload_id/invitation_attempts
        InvitationAttemptsController-->>Agent: Display invitation attempts status
    end
Loading

Tout le code au niveau des models, des jobs et des controllers sur cette partie a été namespacé dans des dossiers user_list_upload.

Choix techniques

Architecture de données

J'introduis pour ce parcours trois nouvelles tables:

  • user_list_uploads: c'est celle qui contient le nom du fichier uploadé, sa structure (= une organisation ou un département) ainsi que la catégorie sur laquelle on dépose le fichier (cette dernière est optionnelle)

  • user_rows: ce sont toutes les lignes du fichier. Chaque colonne correspond à une cellule du fichier. On y ajoute un matching_user_id, qui est l'id de l'usager matchant les données d'identification présentes sur cette ligne. Lorsqu'il n'est pas setté, le matching_user_id est recherché et assigné dans un before_save AR callback (voir concern UserListUpload::UserRow::MatchingUser). Les booléens marked_for_user_save et marked_for_invitation servent à indiquer les lignes sélectionnées pour la création et l'invitation.

  • user_save_attempts: elle représente la tentative de sauvegarde d'un usager à partir d'une user_row. Elle a un état de success et est rattachée à l'usager sauvegardé lorsque ça a fonctionné. On sauvegarde aussi les messages d'erreur dans la colonne service_errors lorsque la tentative a échoué.

  • invitation_attempts: Analogues aux user_save_attempts, ces records contiennent les infos liées au succès ou non des envois d'invitations liés à une user_row. On y a en plus une colonne format pour indiquer qu'il s'agit d'une invitation sms ou email.

erDiagram
    user_list_uploads {
        id bigint PK
        agent_id bigint FK
        structure_id bigint FK
        structure_type string FK
        category_configuration_id bigint FK
        created_at timestamp
        updated_at timestamp
    }

    user_rows {
        id bigint PK
        user_list_upload_id bigint FK
        matching_user_id bigint FK "optional"
        first_name string
        last_name string
        title string
        role string
        email string
        phone_number string
        birth_date date
        affiliation_number string
        department_internal_id string
        france_travail_id string
        nir string "encrypted"
        address string
        organisation_search_terms string
        referent_email string
        tag_values string[]
        cnaf_data jsonb
        marked_for_user_save boolean
        marked_for_invitation boolean
        created_at timestamp
        updated_at timestamp
    }

    user_save_attempts {
        id bigint PK
        user_row_id bigint FK
        user_id bigint FK "optional"
        success boolean
        service_errors string[]
        error_type string
        internal_error_message text
        created_at timestamp
        updated_at timestamp
    }

    invitation_attempts {
        id bigint PK
        user_row_id bigint FK
        invitation_id bigint FK "optional"
        format string "enum(sms,email)"
        success boolean
        service_errors string[]
        internal_error_message text
        created_at timestamp
        updated_at timestamp
    }

    user_list_uploads ||--o{ user_rows : "has many"
    user_rows ||--o{ user_save_attempts : "has many"
    user_rows ||--o{ invitation_attempts : "has many"
Loading
Précédente implémentation

Dans une première implémentation, je n'avais qu'une seule table user_list_uploads qui avait une colonne user_list. Cette colonne était un array de hash représentant exactement les mêmes données que celles présentes dans les table user_rows, user_save_attempts, et invitation_attempts.
Des PORO remplaçaient les models AR qu'on a aujourd'hui et la logique était la même.
L'idée derrière ça était de limiter le nombres de requêtes SQL sur un même upload puisqu'on n'interagissait avec un seul record.

Seulement voilà, Le fait d'instancier tous ces PORO à chaque requête rendait le temps de réponse lent et l'expérience désagréable pour des gros fichiers (~+1s sur les pages chaque 100 lignes ajoutées). J'ai fait un peu d'optimisation qui a réduit le temps de réponse mais c'était toujours insuffisant. J'ai alors rapidement changé de cap et voir si stocker les infos dans des tables séparées améliorerait la performance.

En se faisant j'ai remarqué une nette amélioration des performances en local: le peu que je perdais en requêtes SQL supplémentaires je le gagnais en temps de rendu du html.
Mon intuition derrière ces améliorations est que Rails est conçu pour optimiser le rendu de grosses collections AR et que ces optimisations ne sont pas faites sur des simples classes ruby.
Cette intuition demanderait à être confortée par un benchmark précis pour être confirmée.

En testant en local l'expérience reste fluide pour des listes allant jusqu'à 500 usagers.

Classe UserListUpload::Collection

J'ai créé une classe ruby spécifique qui s'instancie au niveau du model user_List_upload qui permet de gérer les différentes user_rows de l'upload.
J'ai préféré isolé cette partie du reste du model pour plus de clarté.

Batch update

J'ai importé la gem active-record-import pour que les update de plusieurs lignes en même temps ne trigger qu'une seule requête SQL.
Je ne l'utilise pas à la création des user_rows cependant, j'ai préféré passer par des nested attributes directement pour être sûr qu'on appelle tous les callbacks du model (non appelés via les imports).

Interactions côté serveur

L'idée ici était d'avoir un parcours très fluide avec des pages très interactives pour l'utilisateur tout en gardant la logique métier côté serveur et refléter ces différents états avec du code ruby.

Ainsi pratiquement tous les endpoints renvoient du html et sont rendus de manière fluide par Turbo 8, qui remplace seulement les parties du DOM qui ont changé (via idiomorph).
Les différents états de changements et de statuts des lignes du fichier sont gérées au niveau du model user_row.
Les helpers user_list_upload_helper, user_save_attempts_helper et invitation_attempts_helper servent à régler l'affichage en fonction de ces états.

Dans un souci d'optimisation, certaines réponses renvoient via des turbo streams du html qui va directement viser les éléments de la page où s'insérer. Ce sont les requêtes permettant d'afficher et de cacher le détails d'un usager (/show_details et /hide_details) ainsi que les requêtes permettant d'afficher l'édition d'une cellule.
Dans une première implémentation, ces petites interactions ne déclenchaient pas de requêtes et on avait du code js pour afficher ou non ces éléments. Seulement cela posait des problèmes de performances pour les grosses listes de centaines d'usagers car ça obligeait le serveur à répondre avec tout ce html supplémentaire à la première requête.

Certains endpoints renvoient tout de même du json: ceux permettant d'assigner une organisation lorsque celle-ci n'est pas retrouvée à l'upload du département.

Interactions côté client

La feature nécessite bien sûr aussi d'écrire pas mal de code côté pour le browser afin d'avoir une UX optimale.
On introduit donc plusieurs controller stimulus, les principaux étant:

  • user_list_upload_controller.js: sert à parser le fichier des usagers que l'on upload, en extraire les informations qui nous intéressent, puis crée et soumet le formulaire destiné à appeler l'endpoint POST /user_list_uploads
  • enrich_with_cnaf_data_controller.js: sert à parser le fichier le données de contact cnaf, voir s'il y a des usagers dans la l'upload qui match avec ces données de contact, puis crée et soumet le formulaire destiné à enrichir la liste avec ces données POST /user_list_uploads/:user_list_upload_id/enrich_with_cnaf_data
  • submit_selected_uids.js: gère le fait de sélectionner les ids des lignes que l'on a coché avant d'appeler POST user_list_uploads/:user_list_upload_id/user_save_attempts/create_many ou POST user_list_uploads/:user_list_upload_id/invitation_attempts/create_many

Création et invitations en masse

Lorsqu'un usager sélectionne puis soumet les usagers à créer ou inviter en masse (endpoints POST user_list_uploads/:user_list_upload_id/user_save_attempts/create_many ou POST user_list_uploads/:user_list_upload_id/invitation_attempts/create_many), voici ce qu'il se passe:

  • On update les user_rows en question pour les marquer comme sélectionnées pour la création ou l'invitation (marked_for_user_save ou marked_for_invitation)

  • On enqueue un job UserListUpload::SaveUsersJob ou UserListUpload::InviteUsersJob. Ces jobs vont itérer sur les user_rows sélectionnées et vont appeler les méthodes permettant de sauvegarder un usager ou lancer les invitations. On fait le choix pour l'instant de faire ça en série dans un seul job pour ne pas submerger nos workers et les API externes appelés dans ces process. On pourra si nécessaire revoir le mécanisme et paralléliser les jobs par batch d'usagers.

  • Les pages permettant de suivre les statuts de création/d'invitation sont constamment rafraichies via le stimulus controller refresh_page_periodically.js qui trigger un refresh toutes les secondes. Ce code est appelé tant que toutes les sauvegardes ou invitations n'ont pas été toutes tentées. Le refresh via Turbo fait en sorte que seules les éléments ayant changés soient updatés dans le DOM. On choisit de le faire explicitement plutôt que lancer les refresh via des websocket (via la méthode broadcast_refreshes) pour 2 raisons:

    • Le refresh via websocket est triggered quand un job Turbo::BroadcastTurboStreamJob est exécuté. Or beaucoup de jobs sont enqueued à la sauvegarde d'un usager, ce qui fait que les job de refresh seraient possiblement exécutés tardivement. Cela entrainerait une expérience désagréable avec une barre d'état n'avançant pas régulièrement.
    • On sait ici que l'état change continuellement, donc autant le faire explicitement

Ce qu'il reste à faire

J'ai déjà remarqué certaines choses que l'on devra ajouter à l'implémentation actuelle:

  • Avoir un mécanisme de retry sur les pages d'index des sauvegardes d'usagers et d'index des invitations, car pour le moment rien ne permet de le faire
  • Avoir des tests d'intégration plus poussés. Pour l'instant on a qu'un seul test de toute la feature pour le happy path.

Pour la review

Comme souhaité par l'équipe, j'ai fait une grosse PR regroupant le code la feature de bout en bout.
Malheureusement je me suis un peu emmêlé les pinceaux dans les commits donc le premier commit regroupe déjà la fonctionnalité dans son entièreté, j'en suis désolé 🙏 .

Ce que je suggère pour review c'est de faire les étapes une par une comme montré dans le diagramme et regarder le code MVC associé.
Et bien sûr reste bien sûr disponible si vous avez besoin d'infos 🏃‍♂️ !

@aminedhobb aminedhobb self-assigned this Jan 22, 2025
@aminedhobb aminedhobb marked this pull request as ready for review January 22, 2025 17:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[Upload] - liens maquettes
1 participant