From a8f3277a34918a4daeb37d7724ee476df41a0b4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Villeneuve?= <4990201+Michaelvilleneuve@users.noreply.github.com> Date: Wed, 22 Jan 2025 16:53:52 +0100 Subject: [PATCH 1/7] fix(teleprocedure): change wording (#2582) --- app/views/website/teleprocedure_landings/_13.html.erb | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/app/views/website/teleprocedure_landings/_13.html.erb b/app/views/website/teleprocedure_landings/_13.html.erb index febd66f91..a8c9b36e4 100644 --- a/app/views/website/teleprocedure_landings/_13.html.erb +++ b/app/views/website/teleprocedure_landings/_13.html.erb @@ -1,6 +1,5 @@ -

Si vous résidez dans l’une des 29 communes listées ci-dessous, vous êtes concerné par une expérimentation du Conseil départemental des Bouches-du-Rhône visant à faciliter votre prise de rendez-vous RSA.

-
À la suite de votre demande de RSA, un email et/ou un SMS va vous être transmis par le Conseil départemental pour fixer un premier rendez-vous d’orientation. Veuillez cliquer sur le lien contenu dans ce mail/SMS pour choisir un créneau à votre convenance.
-
-

Communes concernées : Marseille - 2e arrondissement, Marseille - 3e arrondissement, Arles, Aureille, Barbentane, Baux-de-Provence, Boulbon, Cabannes, Chateaurenard, Eygalières, Eyragues, Fontvieille, Graveson, Maillane, Mas-Blanc-des-Alpilles, Maussane-les-Alpilles, Saint-Pierre-de-Mézoargues, Molleges, Mouries, Noves, Orgon, Paradou, Plan-d'Orgon, Rognonas, Saint-Andiol, Saint-Etienne-du-Gres, Saintes-Maries-de-la-Mer, Saint-Martin-de-Crau, Saint-Rémy-de-Provence, Tarascon, Verquieres.

-
-

Si vous ne résidez pas dans l’une des 29 communes listées ci-dessus, vous n’êtes pas concerné par ces modalités de prise de rendez-vous et serez convié à votre rendez-vous d’orientation par courrier postal avec accusé de réception.

+

Vous pouvez désormais choisir le jour et l’heure, les plus appropriés à votre emploi du temps, pour rencontrer le conseiller d’orientation qui définira avec vous les démarches et actions à mettre en œuvre pour faciliter votre insertion.

+ +

À la suite de votre demande de RSA, un email et/ou un SMS vous sera transmis par le Conseil départemental pour fixer votre premier rendez-vous d’orientation. Cliquez sur le lien contenu dans ce mail/SMS pour choisir un créneau à votre convenance.

+ +

Simple, rapide, facile le service « RENDEZ-VOUS INSERTION » est disponible sur tous les Pôles d’insertion des Bouches-du- Rhône est accessible en un clic.

From ffef538eb2d4c224321a6e584368874f1618eb2c Mon Sep 17 00:00:00 2001 From: Neuville Romain Date: Wed, 22 Jan 2025 17:29:04 +0100 Subject: [PATCH 2/7] feat(crisp): add crisp chatbox for connected agents (#2561) * feat(crisp): add crisp chatbox for connected agents * use a stimulus controller only and a feature flag * better naming * apply Amines suggestions --- .env.example | 3 + app/controllers/application_controller.rb | 1 + app/controllers/concerns/crisp_concern.rb | 18 ++++++ .../controllers/crisp_controller.js | 60 +++++++++++++++++++ app/models/agent.rb | 6 ++ app/views/common/_crisp.html.erb | 6 ++ app/views/layouts/_application_base.html.erb | 1 + config/anonymizer.yml | 1 + .../initializers/content_security_policy.rb | 3 +- config/webpack/webpack.config.js | 1 + ...0250122135459_add_crisp_token_to_agents.rb | 13 ++++ db/schema.rb | 5 +- 12 files changed, 115 insertions(+), 3 deletions(-) create mode 100644 app/controllers/concerns/crisp_concern.rb create mode 100644 app/javascript/controllers/crisp_controller.js create mode 100644 app/views/common/_crisp.html.erb create mode 100644 db/migrate/20250122135459_add_crisp_token_to_agents.rb diff --git a/.env.example b/.env.example index e8143c161..ed7659211 100644 --- a/.env.example +++ b/.env.example @@ -52,3 +52,6 @@ CARNET_DE_BORD_API_SECRET=secret_api_token DEPARTMENTS_WHERE_PARCOURS_DISABLED=44 ORGANISATION_IDS_WHERE_STATS_DISABLED= + +CRISP_WEBSITE_ID=change_me +ENABLE_CRISP=true diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index a3e5039cd..159749c77 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -8,6 +8,7 @@ class ApplicationController < ActionController::Base include EnvironmentsHelper include TurboStreamConcern include ModalAgreementsConcern + include CrispConcern protect_from_forgery with: :exception before_action :set_sentry_context diff --git a/app/controllers/concerns/crisp_concern.rb b/app/controllers/concerns/crisp_concern.rb new file mode 100644 index 000000000..9d954ce66 --- /dev/null +++ b/app/controllers/concerns/crisp_concern.rb @@ -0,0 +1,18 @@ +module CrispConcern + extend ActiveSupport::Concern + + included do + before_action :should_display_crisp_chatbox, if: -> { request.get? } + end + + private + + def should_display_crisp_chatbox + if current_agent.nil? || agent_impersonated? || ENV["ENABLE_CRISP"] != "true" + @should_display_crisp_chatbox = false + return + end + + @should_display_crisp_chatbox = true + end +end diff --git a/app/javascript/controllers/crisp_controller.js b/app/javascript/controllers/crisp_controller.js new file mode 100644 index 000000000..8fd59f9ec --- /dev/null +++ b/app/javascript/controllers/crisp_controller.js @@ -0,0 +1,60 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static values = { + displayCrisp: Boolean, + userEmail: String, + userNickname: String, + userCrispToken: String, + }; + + connect() { + if (!this.displayCrispValue) { + // If the user is logged out from the app but crisp is still loaded, we loggout the user from crisp + if (window.$crisp) { this.logout(); }; + return; + } + + if (window.CRISP_TOKEN_ID === this.userCrispTokenValue) { + // If the user is already logged in, we don't need to do anything + return; + } + + const user = { + email: this.userEmailValue, + nickname: this.userNicknameValue, + crispToken: this.userCrispTokenValue, + }; + + this.initCrisp(user); + } + + initCrisp(user) { + window.$crisp = []; + window.CRISP_WEBSITE_ID = process.env.CRISP_WEBSITE_ID; + + if (user) { + window.CRISP_TOKEN_ID = user.crispToken; + window.$crisp.push(["set", "user:email", [user.email]]); + window.$crisp.push(["set", "user:nickname", [user.nickname]]); + } + + if (!document.querySelector("script[src='https://client.crisp.chat/l.js']")) { + const crispScriptTag = document.createElement("script"); + crispScriptTag.async = true; + crispScriptTag.src = "https://client.crisp.chat/l.js"; + + const firstScriptTag = document.getElementsByTagName("head")[0]; + firstScriptTag.appendChild(crispScriptTag); + } + } + + logout() { + if (window.$crisp) { + window.CRISP_TOKEN_ID = null; + window.$crisp.push(["do", "session:reset"]); + window.$crisp.push(["do", "session:destroy"]); + window.$crisp.push(["do", "chat:hide"]); + } + } +} diff --git a/app/models/agent.rb b/app/models/agent.rb index 3a7e434fa..7cd732037 100644 --- a/app/models/agent.rb +++ b/app/models/agent.rb @@ -28,6 +28,8 @@ class Agent < ApplicationRecord scope :super_admins, -> { where(super_admin: true) } scope :with_last_name, -> { where.not(last_name: nil) } + before_create :generate_crisp_token + def delete_organisation(organisation) organisations.delete(organisation) save! @@ -59,6 +61,10 @@ def with_rdv_solidarites_session(&) private + def generate_crisp_token + self.crisp_token ||= SecureRandom.uuid + end + # This is to make sure an agent can't be set as super_admin through an agent creation or update in the app. # To set an agent as superadmin a developer should use agent#update_column. def cannot_save_as_super_admin diff --git a/app/views/common/_crisp.html.erb b/app/views/common/_crisp.html.erb new file mode 100644 index 000000000..e70618c02 --- /dev/null +++ b/app/views/common/_crisp.html.erb @@ -0,0 +1,6 @@ +
+ data-crisp-user-email-value="<%= current_agent&.email %>" + data-crisp-user-nickname-value="<%= current_agent&.to_s %>" + data-crisp-user-crisp-token-value="<%= current_agent&.crisp_token %>"> +
diff --git a/app/views/layouts/_application_base.html.erb b/app/views/layouts/_application_base.html.erb index cff633731..90f533d50 100644 --- a/app/views/layouts/_application_base.html.erb +++ b/app/views/layouts/_application_base.html.erb @@ -29,6 +29,7 @@ <%= yield %> <%= render 'common/accept_cgu_modal' if @should_display_accept_cgu %> <%= render 'common/accept_dpa_modal' if @should_display_accept_dpa %> + <%= render 'common/crisp' %> <%= render 'common/footer' %> diff --git a/config/anonymizer.yml b/config/anonymizer.yml index c2c125cf7..aa8dc804a 100644 --- a/config/anonymizer.yml +++ b/config/anonymizer.yml @@ -26,6 +26,7 @@ tables: - email - first_name - last_name + - crisp_token non_anonymized_column_names: - super_admin - last_sign_in_at diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index 190f5a88c..c9a272f7c 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -7,6 +7,7 @@ s3_bucket = "rdv-insertion-medias-production.s3.fr-par.scw.cloud" rdv_solidarites = ENV["RDV_SOLIDARITES_URL"] matomo = "matomo.inclusion.beta.gouv.fr" +crisp = ["*.crisp.chat", "wss://client.relay.crisp.chat"] sentry = "sentry.incubateur.net" maze = "*.maze.co" inclusion_connect = ENV["INCLUSION_CONNECT_BASE_URL"] @@ -21,7 +22,7 @@ policy.object_src :none policy.script_src :self, :https, :unsafe_inline policy.style_src :self, :https, :unsafe_inline - policy.connect_src :self, rdv_solidarites, sentry, matomo, inclusion_connect, maze + policy.connect_src :self, rdv_solidarites, sentry, matomo, inclusion_connect, maze, *crisp policy.worker_src :self, :blob # Specify URI for violation reports # policy.report_uri "/csp-violation-report-endpoint" diff --git a/config/webpack/webpack.config.js b/config/webpack/webpack.config.js index ff03ed6dc..cc94d42c2 100644 --- a/config/webpack/webpack.config.js +++ b/config/webpack/webpack.config.js @@ -51,6 +51,7 @@ module.exports = { "process.env.CARNET_DE_BORD_URL": JSON.stringify(process.env.CARNET_DE_BORD_URL), "process.env.RAILS_ENV": JSON.stringify(process.env.RAILS_ENV), "process.env.MATOMO_CONTAINER_ID": JSON.stringify(process.env.MATOMO_CONTAINER_ID), + "process.env.CRISP_WEBSITE_ID": JSON.stringify(process.env.CRISP_WEBSITE_ID), "process.env.MAZE_API_KEY": JSON.stringify(process.env.MAZE_API_KEY), }), // Include plugins diff --git a/db/migrate/20250122135459_add_crisp_token_to_agents.rb b/db/migrate/20250122135459_add_crisp_token_to_agents.rb new file mode 100644 index 000000000..425cb730f --- /dev/null +++ b/db/migrate/20250122135459_add_crisp_token_to_agents.rb @@ -0,0 +1,13 @@ +class AddCrispTokenToAgents < ActiveRecord::Migration[7.0] + def up + add_column :agents, :crisp_token, :string + + Agent.find_each do |agent| + agent.update_column(:crisp_token, SecureRandom.uuid) + end + end + + def down + remove_column :agents, :crisp_token + end +end diff --git a/db/schema.rb b/db/schema.rb index ed361432c..9ce1d028a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2025_01_20_125249) do +ActiveRecord::Schema[7.1].define(version: 2025_01_22_135459) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -87,6 +87,7 @@ t.string "inclusion_connect_open_id_sub" t.datetime "connected_with_agent_connect_at" t.datetime "cgu_accepted_at" + t.string "crisp_token" t.index ["email"], name: "index_agents_on_email", unique: true t.index ["inclusion_connect_open_id_sub"], name: "index_agents_on_inclusion_connect_open_id_sub", unique: true, where: "(inclusion_connect_open_id_sub IS NOT NULL)" t.index ["rdv_solidarites_agent_id"], name: "index_agents_on_rdv_solidarites_agent_id", unique: true @@ -610,4 +611,4 @@ add_foreign_key "tag_users", "users" add_foreign_key "unavailable_creneau_logs", "organisations" add_foreign_key "webhook_receipts", "webhook_endpoints" -end \ No newline at end of file +end From a29bd9e5a77d340c7b0b60068c2d7b30277de7f7 Mon Sep 17 00:00:00 2001 From: Neuville Romain Date: Thu, 23 Jan 2025 18:35:01 +0100 Subject: [PATCH 3/7] fix(bug): fix orientations update causing 500 (#2585) * fix(bug): fix orientations update causing 500 * non regression test * apply Amines review --- .../users/orientations_controller.rb | 4 +-- ...stream.erb => after_save.turbo_stream.erb} | 0 .../agent_can_add_user_orientations_spec.rb | 28 +++++++++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) rename app/views/users/orientations/{create.turbo_stream.erb => after_save.turbo_stream.erb} (100%) diff --git a/app/controllers/users/orientations_controller.rb b/app/controllers/users/orientations_controller.rb index 7d1e5a214..50381dcde 100644 --- a/app/controllers/users/orientations_controller.rb +++ b/app/controllers/users/orientations_controller.rb @@ -1,7 +1,7 @@ module Users class OrientationsController < ApplicationController before_action :set_user, :set_organisations, :set_agents, only: [:new, :edit, :create, :update] - before_action :set_orientations, only: [:create] + before_action :set_orientations, only: [:create, :update] before_action :set_orientation, only: [:edit, :update, :destroy] before_action :set_agent_ids_by_organisation_id, :set_orientation_types, only: [:new, :edit] @@ -83,7 +83,7 @@ def reloaded_user_orientations def save_orientation_and_redirect @should_notify_organisation = new_organisation? if save_orientation.success? - render :create + render :after_save elsif save_orientation.shrinkeable_orientation.present? turbo_stream_confirm_update_anterior_ends_at_modal else diff --git a/app/views/users/orientations/create.turbo_stream.erb b/app/views/users/orientations/after_save.turbo_stream.erb similarity index 100% rename from app/views/users/orientations/create.turbo_stream.erb rename to app/views/users/orientations/after_save.turbo_stream.erb diff --git a/spec/features/agent_can_add_user_orientations_spec.rb b/spec/features/agent_can_add_user_orientations_spec.rb index 84608a95f..df700c75c 100644 --- a/spec/features/agent_can_add_user_orientations_spec.rb +++ b/spec/features/agent_can_add_user_orientations_spec.rb @@ -91,6 +91,34 @@ expect(page).to have_content("non renseigné") end + it "can update existing orientation and open organisation email notification if needed" do + orientation = create(:orientation, + user: user, + starts_at: "2023-07-03", + orientation_type: orientation_type_social, + organisation: organisation, + agent: organisation_agents.first) + + visit organisation_user_path(organisation_id: organisation.id, id: user.id) + click_link("Parcours") + + within("#orientation_#{orientation.id}") do + find("i.ri-pencil-fill").click + end + + page.select orientation_type_pro.name, from: "orientation[orientation_type_id]" + page.select "Asso 26", from: "orientation_organisation_id" + page.select "Jean-Paul ROUVE", from: "orientation_agent_id" + + click_button "Enregistrer" + expect(page).to have_no_content("Informer l’organisation par email") + click_button "Envoyer" + + expect(page).to have_content("Professionnelle") + expect(page).to have_content("Asso 26") + expect(page).to have_content("Jean-Paul ROUVE") + end + it "open organisation email notification modal when adding an orientation to another organisation" do visit organisation_user_path(organisation_id: organisation.id, id: user.id) From 1e7d96dcb7612196ff4630c138c7cf93449cb79d Mon Sep 17 00:00:00 2001 From: Neuville Romain Date: Thu, 23 Jan 2025 18:37:45 +0100 Subject: [PATCH 4/7] feat(crisp): open chatbox for better visibility on first visit (#2584) * feat(crisp): open chatbox for better visibility on first visit * apply Amines review --- app/javascript/controllers/crisp_controller.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/javascript/controllers/crisp_controller.js b/app/javascript/controllers/crisp_controller.js index 8fd59f9ec..80f256729 100644 --- a/app/javascript/controllers/crisp_controller.js +++ b/app/javascript/controllers/crisp_controller.js @@ -27,6 +27,7 @@ export default class extends Controller { }; this.initCrisp(user); + this.handleFirstVisit(); } initCrisp(user) { @@ -49,6 +50,16 @@ export default class extends Controller { } } + handleFirstVisit() { + const firstVisit = localStorage.getItem("crispFirstVisit"); + if (!firstVisit) { + window.$crisp.push(["on", "session:loaded", () => { + window.$crisp.push(["do", "chat:open"]); + localStorage.setItem("crispFirstVisit", "true"); + }]); + } + } + logout() { if (window.$crisp) { window.CRISP_TOKEN_ID = null; From 324860d5e95bafc7e30f464ac8869a1451236bbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Villeneuve?= <4990201+Michaelvilleneuve@users.noreply.github.com> Date: Mon, 27 Jan 2025 12:04:43 +0100 Subject: [PATCH 5/7] fix(search): prevent sending tag_ids as string (#2587) --- app/views/users/_search_form.html.erb | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/views/users/_search_form.html.erb b/app/views/users/_search_form.html.erb index ec5859157..59c20ba15 100644 --- a/app/views/users/_search_form.html.erb +++ b/app/views/users/_search_form.html.erb @@ -1,7 +1,13 @@ <%= form_with method: :get, url: url, local: true do |form| %>
<% url_params.except(:search_query, :motif_category_id).each do |key, value| %> - <%= form.hidden_field key, value: value %> + <% if value.is_a?(Array) %> + <% value.each do |v| %> + <%= form.hidden_field key.to_s + "[]", value: v %> + <% end %> + <% else %> + <%= form.hidden_field key, value: value %> + <% end %> <% end %> <%= form.text_field :search_query, placeholder: "Prénom, nom, email, n° CAF...", class: "form-control mb-0", value: params[:search_query] %> From d3e51e47268c31370e26bc38e90e96f2fd410b47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Villeneuve?= <4990201+Michaelvilleneuve@users.noreply.github.com> Date: Mon, 27 Jan 2025 13:02:44 +0100 Subject: [PATCH 6/7] fix(users): search form minor change (#2589) * prevent sending tag_ids as string * minor fix --- app/views/users/_search_form.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/users/_search_form.html.erb b/app/views/users/_search_form.html.erb index 59c20ba15..050f325ee 100644 --- a/app/views/users/_search_form.html.erb +++ b/app/views/users/_search_form.html.erb @@ -3,7 +3,7 @@ <% url_params.except(:search_query, :motif_category_id).each do |key, value| %> <% if value.is_a?(Array) %> <% value.each do |v| %> - <%= form.hidden_field key.to_s + "[]", value: v %> + <%= form.hidden_field key, value: v, multiple: true %> <% end %> <% else %> <%= form.hidden_field key, value: value %> From 7d9b1af2655018c8c1cfa84c20eb2f3f63d7c8fb Mon Sep 17 00:00:00 2001 From: Neuville Romain Date: Wed, 29 Jan 2025 16:36:22 +0100 Subject: [PATCH 7/7] feat(france-travail): add rendez-vous-partenaire api webhooks for participation v2 (#2497) * feat(france-travail): add rendez-vous-partenaire api webhooks for participation v2 * add base logic services * reuse webhook_receipts logic in standard webhooks * fix schema * move lock to job * fix linter * fix retrieve_access_token_spec * fixed anonymize * Testing * fixing last tests * fix linter * remove com * apply Amines review * fix test, add endpoint to mm alert on failure * concern for participation payload and fix a test * concern for participation payload mistake * better SRI for FTClient, use another class for headers * fix test * use call_service * distinct logic for create/update/delete jobs and services, better delete logic * apply recent change on FT apis, scope, url, headers * change old env example, fix addess issue in payload, do not send webhook on update for old rdvs * fix test * do not retry FT jobs if user is not found * raise error in service and catch in jobs * activate ft webhooks for one cd only * apply Amines suggestions --- .env.example | 9 +- .env.test | 5 +- app/clients/france_travail_client.rb | 33 +++++ .../france_travail/base_job.rb | 17 +++ .../create_participation_job.rb | 9 ++ .../delete_participation_job.rb | 12 ++ .../update_participation_job.rb | 9 ++ .../send_france_travail_webhook_job.rb | 10 -- .../participation/france_travail_payload.rb | 92 +++++++++++++ .../participation/france_travail_webhooks.rb | 46 +++++++ .../concerns/rdv/france_travail_webhooks.rb | 91 ------------- app/models/participation.rb | 7 +- app/models/rdv.rb | 1 - app/models/webhook_receipt.rb | 3 - .../concerns/webhooks/receipt_handler.rb | 41 ++++++ .../build_user_authenticated_headers.rb | 26 ++++ .../create_participation.rb | 49 +++++++ .../delete_participation.rb | 45 ++++++ .../retrieve_access_token.rb | 91 +++++++++++++ .../france_travail_api/retrieve_user_token.rb | 61 +++++++++ .../update_participation.rb | 44 ++++++ .../send_france_travail_webhook.rb | 69 ---------- .../outgoing_webhooks/send_webhook.rb | 35 ++--- .../retrieve_france_travail_access_token.rb | 87 ------------ app/views/follow_ups/_follow_up.html.erb | 2 +- config/anonymizer.yml | 2 + ..._add_france_travail_id_to_participation.rb | 5 + db/schema.rb | 1 + spec/clients/france_travail_client_spec.rb | 72 ++++++++++ spec/factories/department.rb | 4 + spec/factories/user.rb | 5 + .../create_participation_job_spec.rb | 72 ++++++++++ .../delete_participation_job_spec.rb | 87 ++++++++++++ .../update_participation_job_spec.rb | 117 ++++++++++++++++ .../france_travail_payload_spec.rb | 128 ++++++++++++++++++ .../rdv/france_travail_webhooks_spec.rb | 79 ----------- spec/rails_helper.rb | 1 + .../concerns/webhooks/receipt_handler_spec.rb | 67 +++++++++ .../build_user_authenticated_headers_spec.rb | 31 +++++ .../create_participation_spec.rb | 54 ++++++++ .../delete_participation_spec.rb | 57 ++++++++ .../retrieve_access_token_spec.rb} | 8 +- .../retrieve_user_token_spec.rb | 66 +++++++++ .../update_participation_spec.rb | 49 +++++++ .../send_france_travail_webhook_spec.rb | 100 -------------- 45 files changed, 1426 insertions(+), 473 deletions(-) create mode 100644 app/clients/france_travail_client.rb create mode 100644 app/jobs/outgoing_webhooks/france_travail/base_job.rb create mode 100644 app/jobs/outgoing_webhooks/france_travail/create_participation_job.rb create mode 100644 app/jobs/outgoing_webhooks/france_travail/delete_participation_job.rb create mode 100644 app/jobs/outgoing_webhooks/france_travail/update_participation_job.rb delete mode 100644 app/jobs/outgoing_webhooks/send_france_travail_webhook_job.rb create mode 100644 app/models/concerns/participation/france_travail_payload.rb create mode 100644 app/models/concerns/participation/france_travail_webhooks.rb delete mode 100644 app/models/concerns/rdv/france_travail_webhooks.rb create mode 100644 app/services/concerns/webhooks/receipt_handler.rb create mode 100644 app/services/france_travail_api/build_user_authenticated_headers.rb create mode 100644 app/services/france_travail_api/create_participation.rb create mode 100644 app/services/france_travail_api/delete_participation.rb create mode 100644 app/services/france_travail_api/retrieve_access_token.rb create mode 100644 app/services/france_travail_api/retrieve_user_token.rb create mode 100644 app/services/france_travail_api/update_participation.rb delete mode 100644 app/services/outgoing_webhooks/send_france_travail_webhook.rb delete mode 100644 app/services/retrieve_france_travail_access_token.rb create mode 100644 db/migrate/20241121170810_add_france_travail_id_to_participation.rb create mode 100644 spec/clients/france_travail_client_spec.rb create mode 100644 spec/jobs/outgoing_webhooks/france_travail/create_participation_job_spec.rb create mode 100644 spec/jobs/outgoing_webhooks/france_travail/delete_participation_job_spec.rb create mode 100644 spec/jobs/outgoing_webhooks/france_travail/update_participation_job_spec.rb create mode 100644 spec/models/concerns/participation/france_travail_payload_spec.rb delete mode 100644 spec/models/concerns/rdv/france_travail_webhooks_spec.rb create mode 100644 spec/services/concerns/webhooks/receipt_handler_spec.rb create mode 100644 spec/services/france_travail_api/build_user_authenticated_headers_spec.rb create mode 100644 spec/services/france_travail_api/create_participation_spec.rb create mode 100644 spec/services/france_travail_api/delete_participation_spec.rb rename spec/services/{retrieve_france_travail_access_token_spec.rb => france_travail_api/retrieve_access_token_spec.rb} (91%) create mode 100644 spec/services/france_travail_api/retrieve_user_token_spec.rb create mode 100644 spec/services/france_travail_api/update_participation_spec.rb delete mode 100644 spec/services/outgoing_webhooks/send_france_travail_webhook_spec.rb diff --git a/.env.example b/.env.example index ed7659211..3ecd372ec 100644 --- a/.env.example +++ b/.env.example @@ -51,7 +51,14 @@ CARNET_DE_BORD_URL=https://demo.carnetdebord.inclusion.beta.gouv.fr CARNET_DE_BORD_API_SECRET=secret_api_token DEPARTMENTS_WHERE_PARCOURS_DISABLED=44 + +# France Travail Recette +FRANCE_TRAVAIL_AUTH_URL=https://proxyproconnect-r.ft-qvr.net/connexion/oauth2/access_token +FRANCE_TRAVAIL_API_URL=https://api-r.ft-qvr.io +FRANCE_TRAVAIL_CLIENT_ID=client_id +FRANCE_TRAVAIL_CLIENT_SECRET=client_secret + ORGANISATION_IDS_WHERE_STATS_DISABLED= CRISP_WEBSITE_ID=change_me -ENABLE_CRISP=true +ENABLE_CRISP=false diff --git a/.env.test b/.env.test index 3706cbfb8..662b352ec 100644 --- a/.env.test +++ b/.env.test @@ -15,7 +15,10 @@ AGENT_CONNECT_CLIENT_SECRET=client_secret # France Travail FRANCE_TRAVAIL_AUTH_URL=https://somefakeauthurl.fr -FRANCE_TRAVAIL_RDV_API_URL=https://francetravailfakerdvurl.fr +FRANCE_TRAVAIL_API_URL=https://francetravailfakerdvurl.fr +FRANCE_TRAVAIL_CLIENT_ID=client_id +FRANCE_TRAVAIL_CLIENT_SECRET=client_secret +FRANCE_TRAVAIL_WEBHOOKS_DEPARTMENTS=83 AGENT_SIGNATURE_KEY=bc995863-5c80-43a3-a31d-0da216e814a4 HOST=http://www.rdv-insertion-test.fake diff --git a/app/clients/france_travail_client.rb b/app/clients/france_travail_client.rb new file mode 100644 index 000000000..a0327e7f0 --- /dev/null +++ b/app/clients/france_travail_client.rb @@ -0,0 +1,33 @@ +class FranceTravailClient + def self.create_participation(payload:, headers:) + Faraday.post( + "#{ENV['FRANCE_TRAVAIL_API_URL']}/partenaire/rendez-vous-partenaire/v1/rendez-vous", + payload.to_json, + headers + ) + end + + def self.update_participation(payload:, headers:) + Faraday.put( + "#{ENV['FRANCE_TRAVAIL_API_URL']}/partenaire/rendez-vous-partenaire/v1/rendez-vous", + payload.to_json, + headers + ) + end + + def self.delete_participation(france_travail_id:, headers:) + Faraday.delete( + "#{ENV['FRANCE_TRAVAIL_API_URL']}/partenaire/rendez-vous-partenaire/v1/rendez-vous/#{france_travail_id}", + {}, + headers + ) + end + + def self.retrieve_user_token(payload:, headers:) + Faraday.post( + "#{ENV['FRANCE_TRAVAIL_API_URL']}/partenaire/rechercher-usager/v2/usagers/par-datenaissance-et-nir", + payload.to_json, + headers + ) + end +end diff --git a/app/jobs/outgoing_webhooks/france_travail/base_job.rb b/app/jobs/outgoing_webhooks/france_travail/base_job.rb new file mode 100644 index 000000000..24efe4b15 --- /dev/null +++ b/app/jobs/outgoing_webhooks/france_travail/base_job.rb @@ -0,0 +1,17 @@ +module OutgoingWebhooks + module FranceTravail + class BaseJob < ApplicationJob + include LockedAndOrderedJobs + + discard_on FranceTravailApi::RetrieveUserToken::UserNotFound + + def self.lock_key(participation_id:, **) + "#{base_lock_key}:#{participation_id}" + end + + def self.job_timestamp(timestamp:, **) + timestamp + end + end + end +end diff --git a/app/jobs/outgoing_webhooks/france_travail/create_participation_job.rb b/app/jobs/outgoing_webhooks/france_travail/create_participation_job.rb new file mode 100644 index 000000000..44b000fc4 --- /dev/null +++ b/app/jobs/outgoing_webhooks/france_travail/create_participation_job.rb @@ -0,0 +1,9 @@ +module OutgoingWebhooks + module FranceTravail + class CreateParticipationJob < BaseJob + def perform(participation_id:, timestamp:) + call_service!(FranceTravailApi::CreateParticipation, participation_id: participation_id, timestamp: timestamp) + end + end + end +end diff --git a/app/jobs/outgoing_webhooks/france_travail/delete_participation_job.rb b/app/jobs/outgoing_webhooks/france_travail/delete_participation_job.rb new file mode 100644 index 000000000..4be89735d --- /dev/null +++ b/app/jobs/outgoing_webhooks/france_travail/delete_participation_job.rb @@ -0,0 +1,12 @@ +module OutgoingWebhooks + module FranceTravail + class DeleteParticipationJob < BaseJob + def perform(participation_id:, france_travail_id:, user_id:, timestamp:) + call_service!(FranceTravailApi::DeleteParticipation, + participation_id: participation_id, + france_travail_id: france_travail_id, + user_id: user_id, timestamp: timestamp) + end + end + end +end diff --git a/app/jobs/outgoing_webhooks/france_travail/update_participation_job.rb b/app/jobs/outgoing_webhooks/france_travail/update_participation_job.rb new file mode 100644 index 000000000..b8f05a1ec --- /dev/null +++ b/app/jobs/outgoing_webhooks/france_travail/update_participation_job.rb @@ -0,0 +1,9 @@ +module OutgoingWebhooks + module FranceTravail + class UpdateParticipationJob < BaseJob + def perform(participation_id:, timestamp:) + call_service!(FranceTravailApi::UpdateParticipation, participation_id: participation_id, timestamp: timestamp) + end + end + end +end diff --git a/app/jobs/outgoing_webhooks/send_france_travail_webhook_job.rb b/app/jobs/outgoing_webhooks/send_france_travail_webhook_job.rb deleted file mode 100644 index 78be88745..000000000 --- a/app/jobs/outgoing_webhooks/send_france_travail_webhook_job.rb +++ /dev/null @@ -1,10 +0,0 @@ -module OutgoingWebhooks - class SendFranceTravailWebhookJob < ApplicationJob - def perform(payload, timestamp) - call_service!( - OutgoingWebhooks::SendFranceTravailWebhook, - payload:, timestamp: - ) - end - end -end diff --git a/app/models/concerns/participation/france_travail_payload.rb b/app/models/concerns/participation/france_travail_payload.rb new file mode 100644 index 000000000..6145b2113 --- /dev/null +++ b/app/models/concerns/participation/france_travail_payload.rb @@ -0,0 +1,92 @@ +module Participation::FranceTravailPayload + extend ActiveSupport::Concern + + # rubocop:disable Metrics/AbcSize + def to_ft_payload + { + id: france_travail_id, + adresse: address, + date: starts_at.to_datetime, + duree: duration_in_min, + information: motif.instruction_for_rdv, + initiateur: france_travail_initiateur, + libelleAdresse: organisation.name, + modaliteContact: france_travail_modalite, + motif: france_travail_motif, + organisme: { + code: france_travail_organisme_code, + emailContact: organisation.email, + idStructure: organisation.safir_code, + libelleStructure: organisation.name, + telephoneContact: organisation.phone_number + }, + statut: france_travail_statut, + telephoneContactUsager: user.phone_number, + theme: motif.name, + typeReception: france_travail_type_reception, + interlocuteur: { + email: agents.first.email, + nom: agents.first.last_name, + prenom: agents.first.first_name + } + } + end + # rubocop:enable Metrics/AbcSize + + private + + # Liste des modalités FT (on ne prend en compte que le physique et le telephone): PHYSIQUE, TELEPHONE, VISIO + def france_travail_modalite + by_phone? ? "TELEPHONE" : "PHYSIQUE" + end + + # Liste des initiateurs FT : USAGER, PARTENAIRE + def france_travail_initiateur + created_by_user? ? "USAGER" : "PARTENAIRE" + end + + # Liste des motifs FT : AUT, ACC, ORI + def france_travail_motif + case motif.motif_category&.motif_category_type + when "rsa_orientation" + "ORI" + when "rsa_accompagnement" + "ACC" + else + "AUT" + end + end + + # Liste des codes organismes FT : IND, FT, CD, DCD, ML, CE + def france_travail_organisme_code + case organisation.organisation_type + when "conseil_departemental" + "CD" + when "france_travail" + "FT" + when "delegataire_rsa" + "DCD" + else + "IND" + end + end + + # Liste des types de réception FT : COL, IND + def france_travail_type_reception + collectif? ? "COL" : "IND" + end + + # Liste des statuts FT : PRIS, EFFECTUE, MODIFIE, ABSENT, ANNULE + def france_travail_statut + case status + when "seen" + "EFFECTUE" + when "excused", "revoked" + "ANNULE" + when "noshow" + "ABSENT" + else + "PRIS" + end + end +end diff --git a/app/models/concerns/participation/france_travail_webhooks.rb b/app/models/concerns/participation/france_travail_webhooks.rb new file mode 100644 index 000000000..60fd21e26 --- /dev/null +++ b/app/models/concerns/participation/france_travail_webhooks.rb @@ -0,0 +1,46 @@ +# For france travail the webhooks are specific, we have to adapt to FT specs as they could not +# implement a system integrating our webhooks, so we separated the two webhooks logic. +module Participation::FranceTravailWebhooks + extend ActiveSupport::Concern + + included do + after_commit on: :create, if: -> { eligible_for_france_travail_webhook? } do + OutgoingWebhooks::FranceTravail::CreateParticipationJob.perform_later( + participation_id: id, timestamp: created_at + ) + end + + after_commit on: :update, if: -> { eligible_for_france_travail_webhook_update? } do + OutgoingWebhooks::FranceTravail::UpdateParticipationJob.perform_later( + participation_id: id, timestamp: updated_at + ) + end + + around_destroy lambda { |participation, block| + if participation.eligible_for_france_travail_webhook? + OutgoingWebhooks::FranceTravail::DeleteParticipationJob.perform_later( + participation_id: id, + france_travail_id: participation.france_travail_id, + user_id: participation.user.id, + timestamp: Time.current + ) + end + + block.call + } + end + + def eligible_for_france_travail_webhook? + organisation.france_travail? && user.birth_date? && user.nir? && france_travail_active_department? + end + + def eligible_for_france_travail_webhook_update? + eligible_for_france_travail_webhook? && france_travail_id? + end + + def france_travail_active_department? + # C'est une condition temporaire, les autorisations des clés d'api de France Travail sont scopés au niveau des CD + # le temps que FT autorise notre clé d'API au niveau national on limitera à un département pour les tests + organisation.department.number.in?(ENV.fetch("FRANCE_TRAVAIL_WEBHOOKS_DEPARTMENTS", "").split(",")) + end +end diff --git a/app/models/concerns/rdv/france_travail_webhooks.rb b/app/models/concerns/rdv/france_travail_webhooks.rb deleted file mode 100644 index 98d391a5d..000000000 --- a/app/models/concerns/rdv/france_travail_webhooks.rb +++ /dev/null @@ -1,91 +0,0 @@ -# For france travail the webhooks are specific, we have to adapt to FT specs as they could not -# implement a system integrating our webhooks, so we separated the two webhooks logic. -module Rdv::FranceTravailWebhooks - extend ActiveSupport::Concern - - included do - after_commit on: :create, if: -> { organisation.france_travail? } do - send_france_travail_webhook(:created) - end - - after_commit on: :update, if: -> { organisation.france_travail? } do - send_france_travail_webhook(:updated) - end - - around_destroy :send_france_travail_webhook_on_destroy, if: -> { organisation.france_travail? } - end - - private - - def send_france_travail_webhook(event) - OutgoingWebhooks::SendFranceTravailWebhookJob.perform_later( - generate_france_travail_payload(event), updated_at - ) - end - - def send_france_travail_webhook_on_destroy - payload = generate_france_travail_payload(:destroyed) - - yield if block_given? - - OutgoingWebhooks::SendFranceTravailWebhookJob.perform_later(payload, updated_at) - end - - # rubocop:disable Metrics/AbcSize - def generate_france_travail_payload(event) - { - "idOrigine" => id, - "libelleStructure" => organisation.name, - "codeSafir" => organisation.safir_code, - "objet" => motif.name, - "nombrePlaces" => max_participants_count, - "idModalite" => france_travail_id_modalite, - "typeReception" => collectif? ? "Collectif" : "Individuel", - "dateRendezvous" => starts_at.to_datetime, - "duree" => duration_in_min, - "initiateur" => created_by, - "address" => lieu&.address, - "conseiller" => agents.map do |agent| - { - "email" => agent.email, - "nom" => agent.last_name, - "prenom" => agent.first_name - } - end, - "participants" => users.map do |user| - { - "nir" => user.nir, - "nom" => user.last_name, - "prenom" => user.first_name, - "civilite" => user.title, - "email" => user.email, - "telephone" => user.phone_number, - "dateNaissance" => user.birth_date - } - end, - "information" => motif.instruction_for_rdv, - "dateAnnulation" => cancelled_at, - "dateFinRendezvous" => ends_at.to_datetime, - "mode" => france_travail_event_mapping[event] - } - end - # rubocop:enable Metrics/AbcSize - - def france_travail_event_mapping - { - created: "création", - updated: "modification", - destroyed: "suppression" - } - end - - # Liste des IDs de modalités FT (on ne prend en compte que le physique et le telephone): - # 1 => PHYSIQUE - # 2 => PHYSIQUE COLLECTIF - # 3 => TELEPHONE - # 5 => VISIO - # 7 => VISIO-CONFERENCE COLLECTIVE - def france_travail_id_modalite - motif.phone? ? 1 : 3 - end -end diff --git a/app/models/participation.rb b/app/models/participation.rb index 358a1558a..fbfed2cb3 100644 --- a/app/models/participation.rb +++ b/app/models/participation.rb @@ -2,6 +2,8 @@ class Participation < ApplicationRecord include Notificable include HasCurrentCategoryConfiguration include RdvParticipationStatus + include Participation::FranceTravailWebhooks + include Participation::FranceTravailPayload belongs_to :rdv belongs_to :follow_up @@ -14,6 +16,7 @@ class Participation < ApplicationRecord has_many :notifications, dependent: :destroy has_many :follow_up_invitations, through: :follow_up, source: :invitations + has_many :agents, through: :rdv has_one :organisation, through: :rdv @@ -28,8 +31,8 @@ class Participation < ApplicationRecord enum created_by: { agent: "agent", user: "user", prescripteur: "prescripteur" }, _prefix: :created_by - delegate :starts_at, :motif_name, - :rdv_solidarites_url, :rdv_solidarites_rdv_id, :instruction_for_rdv, + delegate :starts_at, :motif, :lieu, :collectif?, :by_phone?, :duration_in_min, + :rdv_solidarites_url, :rdv_solidarites_rdv_id, :instruction_for_rdv, :address, to: :rdv delegate :department, :department_id, to: :organisation delegate :phone_number_is_mobile?, :email?, to: :user diff --git a/app/models/rdv.rb b/app/models/rdv.rb index 67cf14b71..2b2ad7b98 100644 --- a/app/models/rdv.rb +++ b/app/models/rdv.rb @@ -7,7 +7,6 @@ class Rdv < ApplicationRecord include Notificable include RdvParticipationStatus include WebhookDeliverable - include Rdv::FranceTravailWebhooks include HasCurrentCategoryConfiguration after_commit :notify_participations_to_users, on: :update, if: :should_notify_users? diff --git a/app/models/webhook_receipt.rb b/app/models/webhook_receipt.rb index 14f54c720..a9a320958 100644 --- a/app/models/webhook_receipt.rb +++ b/app/models/webhook_receipt.rb @@ -2,7 +2,4 @@ class WebhookReceipt < ApplicationRecord belongs_to :webhook_endpoint, optional: true validates :resource_model, :resource_id, :timestamp, presence: true - - # france travail webhooks are not linked to a webhook_endpoint - scope :france_travail, -> { where(webhook_endpoint_id: nil) } end diff --git a/app/services/concerns/webhooks/receipt_handler.rb b/app/services/concerns/webhooks/receipt_handler.rb new file mode 100644 index 000000000..ef1db52c0 --- /dev/null +++ b/app/services/concerns/webhooks/receipt_handler.rb @@ -0,0 +1,41 @@ +module Webhooks + module ReceiptHandler + def with_webhook_receipt(resource_model:, resource_id:, timestamp:, webhook_endpoint_id: nil) + # france travail webhooks are not linked to a webhook_endpoint + return if old_update?(resource_model: resource_model, resource_id: resource_id, timestamp: timestamp, + webhook_endpoint_id: webhook_endpoint_id) + + yield + + create_webhook_receipt(resource_model: resource_model, resource_id: resource_id, timestamp: timestamp, + webhook_endpoint_id: webhook_endpoint_id) + end + + private + + def old_update?(resource_model:, resource_id:, timestamp:, webhook_endpoint_id: nil) + last_receipt = last_webhook_receipt_for_resource(resource_model, resource_id, webhook_endpoint_id) + last_receipt.present? && timestamp <= last_receipt.timestamp + end + + def last_webhook_receipt_for_resource(resource_model, resource_id, webhook_endpoint_id) + WebhookReceipt.where( + resource_model: resource_model, + resource_id: resource_id, + webhook_endpoint_id: webhook_endpoint_id + ).order(timestamp: :desc).first + end + + def create_webhook_receipt(resource_model:, resource_id:, timestamp:, webhook_endpoint_id: nil) + webhook_receipt = WebhookReceipt.new( + resource_model: resource_model, + resource_id: resource_id, + timestamp: timestamp, + webhook_endpoint_id: webhook_endpoint_id + ) + return if webhook_receipt.save + + Sentry.capture_message("Webhook receipt with attributes #{webhook_receipt.attributes} could not be created.") + end + end +end diff --git a/app/services/france_travail_api/build_user_authenticated_headers.rb b/app/services/france_travail_api/build_user_authenticated_headers.rb new file mode 100644 index 000000000..c9b6fc08c --- /dev/null +++ b/app/services/france_travail_api/build_user_authenticated_headers.rb @@ -0,0 +1,26 @@ +module FranceTravailApi + class BuildUserAuthenticatedHeaders < BaseService + def initialize(user:) + @user = user + end + + def call + access_token = call_service!(RetrieveAccessToken).access_token + user_token = call_service!(RetrieveUserToken, user: @user, access_token: access_token).user_token + + # Doc FT : Dans le cadre d'un appel depuis un traitement de type batch, + # renseigner "BATCH" pour pa-identifiant-agent. + # Dans le cadre d'un appel depuis un traitement de type batch, + # renseigner un nom logique de batch pour pa-nom-agent et pa-prenom-agent. + result.headers = { + "ft-jeton-usager" => user_token, + "Authorization" => "Bearer #{access_token}", + "Content-Type" => "application/json", + "Accept" => "application/json", + "pa-identifiant-agent" => "BATCH", + "pa-nom-agent" => "Webhooks Participation RDV-Insertion", + "pa-prenom-agent" => "Webhooks Participation RDV-Insertion" + } + end + end +end diff --git a/app/services/france_travail_api/create_participation.rb b/app/services/france_travail_api/create_participation.rb new file mode 100644 index 000000000..5d25b3a28 --- /dev/null +++ b/app/services/france_travail_api/create_participation.rb @@ -0,0 +1,49 @@ +module FranceTravailApi + class CreateParticipation < BaseService + # https://francetravail.io/data/api/rechercher-usager/rdv-partenaire/documentation#/api-reference/ + include Webhooks::ReceiptHandler + + def initialize(participation_id:, timestamp:) + @participation = Participation.find(participation_id) + @timestamp = timestamp + end + + def call + with_webhook_receipt( + resource_model: "Participation", + resource_id: @participation.id, + timestamp: @timestamp + ) do + send_create_request! + end + end + + private + + def send_create_request! + response = FranceTravailClient.create_participation( + payload: @participation.to_ft_payload, + headers: ft_user_headers + ) + + if response.success? && JSON.parse(response.body)["id"].present? + @participation.update_column(:france_travail_id, JSON.parse(response.body)["id"]) + else + handle_failure!(response) + end + + response + end + + def handle_failure!(response) + fail!( + "Impossible d'appeler l'endpoint de l'api rendez-vous-partenaire FT (Création de Participation).\n" \ + "Status: #{response.status}\n Body: #{response.body}" + ) + end + + def ft_user_headers + call_service!(BuildUserAuthenticatedHeaders, user: @participation.user).headers + end + end +end diff --git a/app/services/france_travail_api/delete_participation.rb b/app/services/france_travail_api/delete_participation.rb new file mode 100644 index 000000000..ed330ad4e --- /dev/null +++ b/app/services/france_travail_api/delete_participation.rb @@ -0,0 +1,45 @@ +module FranceTravailApi + class DeleteParticipation < BaseService + # https://francetravail.io/data/api/rechercher-usager/rdv-partenaire/documentation#/api-reference/ + include Webhooks::ReceiptHandler + + def initialize(participation_id:, france_travail_id:, user_id:, timestamp:) + @participation_id = participation_id + @france_travail_id = france_travail_id + @user = User.find(user_id) + @timestamp = timestamp + end + + def call + with_webhook_receipt( + resource_model: "Participation", + resource_id: @participation_id, + timestamp: @timestamp + ) do + send_request! + end + end + + private + + def send_request! + response = FranceTravailClient.delete_participation( + france_travail_id: @france_travail_id, + headers: ft_user_headers + ) + + handle_failure!(response) unless response.success? + end + + def handle_failure!(response) + fail!( + "Impossible d'appeler l'endpoint de l'api rendez-vous-partenaire FT (Suppression de Participation).\n" \ + "Status: #{response.status}\n Body: #{response.body}" + ) + end + + def ft_user_headers + call_service!(BuildUserAuthenticatedHeaders, user: @user).headers + end + end +end diff --git a/app/services/france_travail_api/retrieve_access_token.rb b/app/services/france_travail_api/retrieve_access_token.rb new file mode 100644 index 000000000..cf983bbd3 --- /dev/null +++ b/app/services/france_travail_api/retrieve_access_token.rb @@ -0,0 +1,91 @@ +module FranceTravailApi + class RetrieveAccessToken < BaseService + # https://francetravail.io/produits-partages/documentation/utilisation-api-france-travail/generer-access-token + API_SCOPES = [ + "api_rechercher-usagerv2 rechercheusager profil_accedant api_rendez-vous-partenairev1 gererRDV" + ].freeze + PATH = "/connexion/oauth2/access_token".freeze + + def call + RedisConnection.with_redis do |redis| + if redis.exists?("france_travail_access_token") + assign_access_token_from_redis(redis) + next + end + + retrieve_france_travail_token(redis) + end + end + + private + + def assign_access_token_from_redis(redis) + result.access_token = redis.get("france_travail_access_token") + end + + def store_token_in_redis(redis) + redis.set("france_travail_access_token", @france_travail_access_token, ex: redis_key_duration) + end + + def redis_key_duration + # we expire the key 60 seconds before the token expires to be sure not to use an expired + # access token when calling FT + @expires_in - 60 + end + + def retrieve_france_travail_token(redis) + ActiveRecord::Base.with_advisory_lock("france_travail_token") do + # we check again in case a token has been retrieved while waiting for the lock to open + next assign_access_token_from_redis(redis) if redis.exists?("france_travail_token") + + request_token + store_token_in_redis(redis) + assign_access_token_from_redis(redis) + end + end + + def request_token + response = connection.post do |req| + req.body = URI.encode_www_form(request_body_params) + end + + if response.success? + response_body = JSON.parse(response.body) + @france_travail_access_token, @expires_in = [ + response_body["access_token"], response_body["expires_in"] + ] + else + fail!( + "la requête d'authentification à FT n'a pas pu aboutir.\n" \ + "Status: #{response.status}\n Body: #{response.body}" + ) + end + end + + def connection + @connection ||= Faraday.new(url: request_url) do |faraday| + faraday.request :url_encoded + faraday.options.timeout = 15.seconds + end + end + + def request_url + uri = URI.join(ENV["FRANCE_TRAVAIL_AUTH_URL"], PATH) + uri.query = request_url_params.to_query + uri.to_s + end + + def request_url_params + { realm: "/agent" } + end + + def request_body_params + { + grant_type: "client_credentials", + client_id: ENV["FRANCE_TRAVAIL_CLIENT_ID"], + client_secret: ENV["FRANCE_TRAVAIL_CLIENT_SECRET"], + scope: API_SCOPES.join(" ") + } + end + end +end diff --git a/app/services/france_travail_api/retrieve_user_token.rb b/app/services/france_travail_api/retrieve_user_token.rb new file mode 100644 index 000000000..a50e16c44 --- /dev/null +++ b/app/services/france_travail_api/retrieve_user_token.rb @@ -0,0 +1,61 @@ +module FranceTravailApi + class RetrieveUserToken < BaseService + class UserNotFound < StandardError; end + # https://francetravail.io/produits-partages/catalogue/rechercher-usager-v2/documentation#/api-reference/ + + def initialize(user:, access_token:) + @user = user + @access_token = access_token + end + + def call + send_request! + result.user_token = @france_travail_user_token + end + + private + + def send_request! + response = FranceTravailClient.retrieve_user_token(payload: user_payload, headers: headers) + @response_body = JSON.parse(response.body) + + if response.success? && !user_not_found?(response) + @france_travail_user_token = @response_body["jetonUsager"] + elsif user_not_found?(response) + raise UserNotFound, "Aucun usager trouvé avec l'id #{@user.id}" + else + fail!( + "Erreur lors de l'appel à l'api recherche-usager FT.\n" \ + "Status: #{response.status}\n Body: #{response.body}" + ) + end + end + + def user_payload + { + dateNaissance: @user.birth_date.to_s, + nir: @user.nir + } + end + + def headers + # Doc FT : Dans le cadre d'un appel depuis un traitement de type batch, + # renseigner "BATCH" pour pa-identifiant-agent. + # Dans le cadre d'un appel depuis un traitement de type batch, + # renseigner un nom logique de batch pour pa-nom-agent et pa-prenom-agent. + { + "Authorization" => "Bearer #{@access_token}", + "Content-Type" => "application/json", + "pa-identifiant-agent" => "BATCH", + "pa-nom-agent" => "Webhooks Participation RDV-Insertion", + "pa-prenom-agent" => "Webhooks Participation RDV-Insertion" + } + end + + def user_not_found?(response) + # Actuellement le code retour S002 est "Aucun approchant n'a été trouvé" mais c'est une 200 + # Ca devrait être un 404, FT est au courant et va corriger, il faudra enlever cette condition. + @response_body["codeRetour"].include?("S002") || response.status == 404 + end + end +end diff --git a/app/services/france_travail_api/update_participation.rb b/app/services/france_travail_api/update_participation.rb new file mode 100644 index 000000000..25928edee --- /dev/null +++ b/app/services/france_travail_api/update_participation.rb @@ -0,0 +1,44 @@ +module FranceTravailApi + class UpdateParticipation < BaseService + # https://francetravail.io/data/api/rechercher-usager/rdv-partenaire/documentation#/api-reference/ + include Webhooks::ReceiptHandler + + def initialize(participation_id:, timestamp:) + @participation = Participation.find(participation_id) + @timestamp = timestamp + end + + def call + with_webhook_receipt( + resource_model: "Participation", + resource_id: @participation.id, + timestamp: @timestamp + ) do + send_update_request! + end + end + + private + + def send_update_request! + response = FranceTravailClient.update_participation( + payload: @participation.to_ft_payload, + headers: ft_user_headers + ) + + handle_failure!(response) unless response.success? + response + end + + def handle_failure!(response) + fail!( + "Impossible d'appeler l'endpoint de l'api rendez-vous-partenaire FT (Update de Participation).\n" \ + "Status: #{response.status}\n Body: #{response.body}" + ) + end + + def ft_user_headers + call_service!(BuildUserAuthenticatedHeaders, user: @participation.user).headers + end + end +end diff --git a/app/services/outgoing_webhooks/send_france_travail_webhook.rb b/app/services/outgoing_webhooks/send_france_travail_webhook.rb deleted file mode 100644 index b6ceed715..000000000 --- a/app/services/outgoing_webhooks/send_france_travail_webhook.rb +++ /dev/null @@ -1,69 +0,0 @@ -module OutgoingWebhooks - class SendFranceTravailWebhook < BaseService - def initialize(payload:, timestamp:) - @payload = payload - @timestamp = timestamp - end - - def call - return if france_travail_rdv_api_url.blank? - - Rdv.with_advisory_lock("france_travail_webhook_#{resource_id}") do - return if old_update? - - send_request! - create_webhook_receipt - end - end - - private - - def resource_id = @payload["idOrigine"] - - def create_webhook_receipt - webhook_receipt = WebhookReceipt.new(resource_model: "Rdv", resource_id:, timestamp: @timestamp) - return if webhook_receipt.save - - Sentry.capture_message("Webhook receipt with attributes #{webhook_receipt.attributes} could not be created.") - end - - def old_update? - last_webhook_receipt_for_resource.present? && @timestamp <= last_webhook_receipt_for_resource.timestamp - end - - def last_webhook_receipt_for_resource - WebhookReceipt.france_travail.where(resource_id:).order(timestamp: :desc).first - end - - def send_request! - response = Faraday.post( - france_travail_rdv_api_url, - @payload.to_json, - request_headers - ) - return if response.success? - - fail!( - "Impossible d'appeler l'endpoint de rdv FT.\n" \ - "Status: #{response.status}\n Body: #{response.body}" - ) - end - - def france_travail_rdv_api_url = ENV["FRANCE_TRAVAIL_RDV_API_URL"] - - def request_headers - { - "Authorization" => "Bearer #{france_travail_access_token}", - "Content-Type" => "application/json" - } - end - - def retrieve_france_travail_access_token - @retrieve_france_travail_access_token ||= call_service!(RetrieveFranceTravailAccessToken) - end - - def france_travail_access_token - retrieve_france_travail_access_token.access_token - end - end -end diff --git a/app/services/outgoing_webhooks/send_webhook.rb b/app/services/outgoing_webhooks/send_webhook.rb index bd8de4bd6..81e15acb0 100644 --- a/app/services/outgoing_webhooks/send_webhook.rb +++ b/app/services/outgoing_webhooks/send_webhook.rb @@ -1,5 +1,7 @@ module OutgoingWebhooks class SendWebhook < BaseService + include Webhooks::ReceiptHandler + def initialize(webhook_endpoint:, webhook_payload:, webhook_signature:) @webhook_endpoint = webhook_endpoint @webhook_payload = webhook_payload.deep_symbolize_keys @@ -8,11 +10,15 @@ def initialize(webhook_endpoint:, webhook_payload:, webhook_signature:) def call ActiveRecord::Base - .with_advisory_lock("send_#{resource_model}_#{resource_id}_to_endpoint_#{@webhook_endpoint_id}") do - return if old_update? - - send_webhook - create_webhook_receipt + .with_advisory_lock("send_#{resource_model}_#{resource_id}_to_endpoint_#{@webhook_endpoint.id}") do + with_webhook_receipt( + resource_model: resource_model, + resource_id: resource_id, + timestamp: timestamp, + webhook_endpoint_id: @webhook_endpoint.id + ) do + send_webhook + end end end @@ -46,25 +52,6 @@ def request_headers }.merge(@webhook_signature) end - def old_update? - last_webhook_receipt_for_resource.present? && timestamp <= last_webhook_receipt_for_resource.timestamp - end - - def last_webhook_receipt_for_resource - @last_webhook_receipt_for_resource ||= WebhookReceipt.where( - webhook_endpoint_id: @webhook_endpoint.id, resource_model:, resource_id: - ).order(timestamp: :desc).first - end - - def create_webhook_receipt - webhook_receipt = WebhookReceipt.new( - webhook_endpoint_id: @webhook_endpoint.id, resource_model:, resource_id:, timestamp: - ) - return if webhook_receipt.save - - Sentry.capture_message("Webhook receipt with attributes #{webhook_receipt.attributes} could not be created.") - end - def error_message_for(response) "Could not send webhook to url #{@webhook_endpoint.url}\n" \ "resource model: #{resource_model}\n" \ diff --git a/app/services/retrieve_france_travail_access_token.rb b/app/services/retrieve_france_travail_access_token.rb deleted file mode 100644 index 413bbe527..000000000 --- a/app/services/retrieve_france_travail_access_token.rb +++ /dev/null @@ -1,87 +0,0 @@ -class RetrieveFranceTravailAccessToken < BaseService - API_SCOPES = ["api_rendezvous-partenairesv1"].freeze - - def call - @redis = Redis.new - - if @redis.exists?("france_travail_access_token") - set_access_token_in_result - return - end - - retrieve_france_travail_token - ensure - @redis.close - end - - private - - def set_access_token_in_result - result.access_token = @redis.get("france_travail_access_token") - end - - def store_token_in_redis - @redis.set("france_travail_access_token", @france_travail_access_token, ex: redis_key_duration) - end - - def redis_key_duration - # we expire the key 60 seconds before the token expires to be sure not to use an expired - # access token when calling FT - @expires_in - 60 - end - - def retrieve_france_travail_token - ActiveRecord::Base.with_advisory_lock("france_travail_token") do - # we check again in case a token has been retrieved while waiting for the lock to open - return set_access_token_in_result if @redis.exists?("france_travail_token") - - request_token - store_token_in_redis - set_access_token_in_result - end - end - - def request_token - response = connection.post do |req| - req.body = URI.encode_www_form(request_body_params) - end - - if response.success? - response_body = JSON.parse(response.body) - @france_travail_access_token, @expires_in = [ - response_body["access_token"], response_body["expires_in"] - ] - else - fail!( - "la requête d'authentification à FT n'a pas pu aboutir.\n" \ - "Status: #{response.status}\n Body: #{response.body}" - ) - end - end - - def connection - @connection ||= Faraday.new(url: request_url) do |faraday| - faraday.request :url_encoded - faraday.options.timeout = 15.seconds - end - end - - def request_url - uri = URI(ENV["FRANCE_TRAVAIL_AUTH_URL"]) - uri.query = request_url_params.to_query - uri.to_s - end - - def request_url_params - { realm: "/partenaire" } - end - - def request_body_params - { - grant_type: "client_credentials", - client_id: ENV["FRANCE_TRAVAIL_API_KEY"], - client_secret: ENV["FRANCE_TRAVAIL_API_SECRET"], - scope: API_SCOPES.join(" ") - } - end -end diff --git a/app/views/follow_ups/_follow_up.html.erb b/app/views/follow_ups/_follow_up.html.erb index 8143e98ac..13c388a26 100644 --- a/app/views/follow_ups/_follow_up.html.erb +++ b/app/views/follow_ups/_follow_up.html.erb @@ -55,7 +55,7 @@ <%= format_date(participation.starts_at) %> - <%= participation.motif_name %> + <%= participation.motif.name %> <% if participation.rdv.rdv_solidarites_rdv_id && policy(participation).edit? %> <%= render "participations/participation_status", participation: participation, category_configuration: category_configuration %> diff --git a/config/anonymizer.yml b/config/anonymizer.yml index aa8dc804a..58fc10fa0 100644 --- a/config/anonymizer.yml +++ b/config/anonymizer.yml @@ -286,6 +286,8 @@ tables: - document_date - table_name: participations + anonymized_column_names: + - france_travail_id non_anonymized_column_names: - user_id - rdv_id diff --git a/db/migrate/20241121170810_add_france_travail_id_to_participation.rb b/db/migrate/20241121170810_add_france_travail_id_to_participation.rb new file mode 100644 index 000000000..33e7ee1db --- /dev/null +++ b/db/migrate/20241121170810_add_france_travail_id_to_participation.rb @@ -0,0 +1,5 @@ +class AddFranceTravailIdToParticipation < ActiveRecord::Migration[7.1] + def change + add_column :participations, :france_travail_id, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 9ce1d028a..c2a507e61 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -386,6 +386,7 @@ t.string "created_by", null: false t.boolean "convocable", default: false, null: false t.bigint "rdv_solidarites_agent_prescripteur_id" + t.string "france_travail_id" t.index ["follow_up_id"], name: "index_participations_on_follow_up_id" t.index ["status"], name: "index_participations_on_status" t.index ["user_id", "rdv_id"], name: "index_participations_on_user_id_and_rdv_id", unique: true diff --git a/spec/clients/france_travail_client_spec.rb b/spec/clients/france_travail_client_spec.rb new file mode 100644 index 000000000..ae18ac551 --- /dev/null +++ b/spec/clients/france_travail_client_spec.rb @@ -0,0 +1,72 @@ +describe FranceTravailClient do + let(:user) { create(:user) } + let(:payload) { { some: "data" } } + let(:france_travail_id) { "ft-123" } + let(:headers) do + { "Authorization" => "Bearer token", "Content-Type" => "application/json", "Accept" => "application/json", + "ft-jeton-usager" => "jeton-usager" } + end + + before do + allow(FranceTravailApi::BuildUserAuthenticatedHeaders).to receive(:call) + .and_return(OpenStruct.new(headers: headers)) + end + + describe "#create_participation" do + before do + stub_request(:post, "#{ENV['FRANCE_TRAVAIL_API_URL']}/partenaire/rendez-vous-partenaire/v1/rendez-vous") + .with(body: payload.to_json, headers: headers) + .to_return(status: 200, body: "", headers: {}) + end + + it "sends a POST request to France Travail API" do + response = described_class.create_participation(payload: payload, headers: headers) + expect(response.status).to eq(200) + end + end + + describe "#update_participation" do + before do + stub_request(:put, "#{ENV['FRANCE_TRAVAIL_API_URL']}/partenaire/rendez-vous-partenaire/v1/rendez-vous") + .with(body: payload.to_json, headers: headers) + .to_return(status: 200, body: "", headers: {}) + end + + it "sends a PUT request to France Travail API" do + response = described_class.update_participation(payload: payload, headers: headers) + expect(response.status).to eq(200) + end + end + + describe "#delete_participation" do + before do + stub_request( + :delete, + "#{ENV['FRANCE_TRAVAIL_API_URL']}/partenaire/rendez-vous-partenaire/v1/rendez-vous/#{france_travail_id}" + ) + .with(headers: headers) + .to_return(status: 200, body: "", headers: {}) + end + + it "sends a DELETE request to France Travail API" do + response = described_class.delete_participation(france_travail_id: france_travail_id, headers: headers) + expect(response.status).to eq(200) + end + end + + describe "#user_token" do + before do + stub_request( + :post, + "#{ENV['FRANCE_TRAVAIL_API_URL']}/partenaire/rechercher-usager/v2/usagers/par-datenaissance-et-nir" + ) + .with(body: payload.to_json, headers: headers) + .to_return(status: 200, body: "", headers: {}) + end + + it "sends a POST request to France Travail API" do + response = described_class.retrieve_user_token(payload: payload, headers: headers) + expect(response.status).to eq(200) + end + end +end diff --git a/spec/factories/department.rb b/spec/factories/department.rb index 516e2b82d..e84b4e6e8 100644 --- a/spec/factories/department.rb +++ b/spec/factories/department.rb @@ -6,5 +6,9 @@ sequence(:region) { |n| "Région n°#{n}" } pronoun { "le" } logo { Rack::Test::UploadedFile.new(Rails.root.join("spec/fixtures/logo.png")) } + + trait :ft_department do + number { 83 } + end end end diff --git a/spec/factories/user.rb b/spec/factories/user.rb index 13f0f24d4..ef6a99030 100644 --- a/spec/factories/user.rb +++ b/spec/factories/user.rb @@ -17,5 +17,10 @@ trait :skip_validate do to_create { |instance| instance.save(validate: false) } end + trait :with_valid_nir do + birth_date { "1985-01-01" } + nir { "185027800608443" } + title { "monsieur" } + end end end diff --git a/spec/jobs/outgoing_webhooks/france_travail/create_participation_job_spec.rb b/spec/jobs/outgoing_webhooks/france_travail/create_participation_job_spec.rb new file mode 100644 index 000000000..a2399d82d --- /dev/null +++ b/spec/jobs/outgoing_webhooks/france_travail/create_participation_job_spec.rb @@ -0,0 +1,72 @@ +describe OutgoingWebhooks::FranceTravail::CreateParticipationJob do + let!(:department) { create(:department, :ft_department) } + let!(:organisation) { create(:organisation, safir_code: "123456", department: department) } + let!(:now) { Time.zone.parse("21/01/2023 23:42:11") } + + before do + travel_to now + allow(described_class).to receive(:perform_later) + end + + describe "callbacks" do + context "when the organisation is france_travail and user is valid" do + let!(:user) { create(:user, :with_valid_nir) } + let!(:rdv) { build(:rdv) } + + context "on creation" do + let!(:participation) { build(:participation, rdv: rdv, user: user, organisation: organisation) } + + it "notifies the creation" do + expect(described_class).to receive(:perform_later) + participation.save + end + end + end + + context "when organisation is not france_travail" do + let!(:organisation) { create(:organisation, safir_code: nil) } + + context "on creation" do + let!(:participation) { build(:participation, organisation: organisation) } + + it "does not send webhook" do + expect(described_class).not_to receive(:perform_later) + participation.save + end + end + end + + context "when organisation is france_travail but user has no nir" do + let!(:user) { create(:user) } + let!(:rdv) { build(:rdv) } + let!(:participation) { build(:participation, rdv: rdv, user: user, organisation: organisation) } + + context "on creation" do + it "does not send webhook" do + expect(described_class).not_to receive(:perform_later) + participation.save + end + end + end + end + + describe "#perform" do + let(:service) { instance_double(FranceTravailApi::CreateParticipation, result: OpenStruct.new) } + let(:participation) { create(:participation) } + let(:timestamp) { Time.current } + + before do + allow(FranceTravailApi::CreateParticipation).to receive(:new).and_return(service) + allow(service).to receive(:call) + end + + it "calls the create participation service" do + described_class.perform_now( + participation_id: participation.id, + timestamp: timestamp + ) + + expect(service).to have_received(:call) + end + end +end diff --git a/spec/jobs/outgoing_webhooks/france_travail/delete_participation_job_spec.rb b/spec/jobs/outgoing_webhooks/france_travail/delete_participation_job_spec.rb new file mode 100644 index 000000000..fd8a64c6c --- /dev/null +++ b/spec/jobs/outgoing_webhooks/france_travail/delete_participation_job_spec.rb @@ -0,0 +1,87 @@ +describe OutgoingWebhooks::FranceTravail::DeleteParticipationJob do + let!(:department) { create(:department, :ft_department) } + let!(:organisation) { create(:organisation, safir_code: "123456", department: department) } + let!(:now) { Time.zone.parse("21/01/2023 23:42:11") } + + before do + travel_to now + allow(described_class).to receive(:perform_later) + end + + describe "callbacks" do + context "when the organisation is france_travail and user is valid" do + let!(:user) { create(:user, :with_valid_nir) } + let!(:rdv) { build(:rdv) } + + context "on deletion" do + let!(:participation) do + create(:participation, rdv: rdv, user: user, organisation: organisation, france_travail_id: "123456") + end + + it "notifies on deletion" do + participation_id = participation.id + france_travail_id = participation.france_travail_id + user_id = user.id + expect(described_class).to receive(:perform_later) + .with( + participation_id: participation_id, + france_travail_id: france_travail_id, + user_id: user_id, + timestamp: now + ) + participation.destroy + end + end + end + + context "when organisation is not france_travail" do + let!(:organisation) { create(:organisation, safir_code: nil) } + + context "on deletion" do + let!(:participation) { create(:participation, organisation: organisation) } + + it "does not send webhook on deletion" do + expect(described_class).not_to receive(:perform_later) + participation.destroy + end + end + end + + context "when organisation is france_travail but user has no nir" do + let!(:user) { create(:user) } + let!(:rdv) { build(:rdv) } + let!(:participation) do + create(:participation, rdv: rdv, user: user, organisation: organisation, france_travail_id: "123456") + end + + context "on deletion" do + it "does not send webhook" do + expect(described_class).not_to receive(:perform_later) + participation.destroy + end + end + end + end + + describe "#perform" do + let(:service) { instance_double(FranceTravailApi::DeleteParticipation, result: OpenStruct.new) } + let(:participation) { create(:participation) } + let(:timestamp) { Time.current } + + before do + allow(FranceTravailApi::DeleteParticipation).to receive(:new).and_return(service) + allow(service).to receive(:call) + end + + it "calls the delete participation service" do + described_class.perform_now( + participation_id: participation.id, + france_travail_id: participation.france_travail_id, + user_id: participation.user_id, + timestamp: timestamp + ) + + expect(service).to have_received(:call) + end + end +end diff --git a/spec/jobs/outgoing_webhooks/france_travail/update_participation_job_spec.rb b/spec/jobs/outgoing_webhooks/france_travail/update_participation_job_spec.rb new file mode 100644 index 000000000..69f1292d9 --- /dev/null +++ b/spec/jobs/outgoing_webhooks/france_travail/update_participation_job_spec.rb @@ -0,0 +1,117 @@ +describe OutgoingWebhooks::FranceTravail::UpdateParticipationJob do + let!(:department) { create(:department, :ft_department) } + let!(:organisation) { create(:organisation, safir_code: "123456", department: department) } + let!(:now) { Time.zone.parse("21/01/2023 23:42:11") } + + before do + travel_to now + allow(described_class).to receive(:perform_later) + end + + describe "callbacks" do + context "when the organisation is france_travail and user is valid" do + let!(:user) { create(:user, :with_valid_nir) } + let!(:rdv) { build(:rdv) } + + context "on update" do + let!(:participation) do + create(:participation, rdv: rdv, user: user, organisation: organisation, france_travail_id: "12345") + end + + it "notifies on update" do + expect(described_class).to receive(:perform_later) + .with( + participation_id: participation.id, + timestamp: participation.updated_at + ) + participation.save + end + end + end + + context "when organisation is not france_travail" do + let!(:organisation) { create(:organisation, safir_code: nil) } + + context "on update" do + let!(:participation) { create(:participation, organisation: organisation) } + + it "does not send webhook" do + expect(described_class).not_to receive(:perform_later) + participation.save + end + end + end + + context "when organisation is france_travail but user has no nir" do + let!(:user) { create(:user) } + let!(:rdv) { build(:rdv) } + let!(:participation) { build(:participation, rdv: rdv, user: user, organisation: organisation) } + + context "on update" do + it "does not send webhook" do + expect(described_class).not_to receive(:perform_later) + participation.save + end + end + end + end + + describe "#perform" do + let(:service) { instance_double(FranceTravailApi::UpdateParticipation, result: service_result) } + let(:participation) { create(:participation) } + let(:timestamp) { Time.current } + let(:service_result) { OpenStruct.new(errors: []) } + + before do + allow(FranceTravailApi::UpdateParticipation).to receive(:new).and_return(service) + allow(service).to receive(:call).and_return(service_result) + end + + context "when service succeeds" do + it "calls the update participation service" do + described_class.perform_now( + participation_id: participation.id, + timestamp: timestamp + ) + + expect(service).to have_received(:call) + end + end + + context "when service fails with regular error" do + before do + allow(service).to receive(:call) + .and_raise(ApplicationJob::FailedServiceError, "Some error") + end + + it "raises a FailedServiceError" do + expect do + described_class.perform_now( + participation_id: participation.id, + timestamp: timestamp + ) + end.to raise_error(ApplicationJob::FailedServiceError) + end + end + + context "when service fails with UserNotFound error" do + before do + allow(service).to receive(:call) + .and_raise(FranceTravailApi::RetrieveUserToken::UserNotFound, "Aucun usager trouvé") + end + + it "discards the job without raising an error" do + expect do + perform_enqueued_jobs do + described_class.perform_now( + participation_id: participation.id, + timestamp: timestamp + ) + end + end.not_to raise_error # Le job est discard quand il y a une erreur UserNotFound + + assert_no_enqueued_jobs + end + end + end +end diff --git a/spec/models/concerns/participation/france_travail_payload_spec.rb b/spec/models/concerns/participation/france_travail_payload_spec.rb new file mode 100644 index 000000000..23538608f --- /dev/null +++ b/spec/models/concerns/participation/france_travail_payload_spec.rb @@ -0,0 +1,128 @@ +describe Participation::FranceTravailPayload, type: :concern do + let(:payload) { participation.to_ft_payload } + let(:organisation) { create(:organisation, organisation_type: "conseil_departemental") } + let(:user) { create(:user, organisations: [organisation]) } + let(:follow_up) { create(:follow_up, user: user) } + let(:motif) { create(:motif, motif_category:) } + let(:motif_category) { create(:motif_category, motif_category_type: "rsa_orientation") } + let(:rdv) { create(:rdv, organisation: organisation, motif: motif) } + let(:participation) { create(:participation, follow_up:, user:, rdv:, status: "unknown") } + + describe "#to_ft_payload" do + it "returns the correct payload structure" do + expect(payload).to include( + id: participation.france_travail_id, + date: participation.starts_at.to_datetime, + duree: participation.duration_in_min, + theme: participation.motif.name + ) + end + end + + describe "france_travail_modalite" do + context "when participation is by phone" do + before { motif.update(location_type: "phone") } + + it "returns TELEPHONE" do + expect(payload[:modaliteContact]).to eq("TELEPHONE") + end + end + + context "when participation is not by phone" do + it "returns PHYSIQUE" do + expect(payload[:modaliteContact]).to eq("PHYSIQUE") + end + end + end + + describe "france_travail_initiateur" do + context "when participation is created by user" do + it "returns USAGER" do + expect(payload[:initiateur]).to eq("USAGER") + end + end + + context "when participation is not created by user" do + before { participation.update(created_by: "agent") } + + it "returns PARTENAIRE" do + expect(payload[:initiateur]).to eq("PARTENAIRE") + end + end + end + + describe "france_travail_motif" do + context "when participation motif category is rsa_orientation" do + it "returns ORI" do + expect(payload[:motif]).to eq("ORI") + end + end + + context "when participation motif category is rsa_accompagnement" do + before { motif_category.update(motif_category_type: "rsa_accompagnement") } + + it "returns ACC" do + expect(payload[:motif]).to eq("ACC") + end + end + + context "when participation motif category is not rsa_orientation or rsa_accompagnement" do + before { allow(participation.motif.motif_category).to receive(:motif_category_type).and_return(nil) } + + it "returns AUT" do + expect(payload[:motif]).to eq("AUT") + end + end + end + + describe "france_travail_organisme_code" do + { + "conseil_departemental" => "CD", + "france_travail" => "FT", + "delegataire_rsa" => "DCD", + "autre" => "IND" + }.each do |organisation_type, expected| + context "when participation organisation type is #{organisation_type}" do + before { organisation.update(organisation_type: organisation_type) } + + it "returns #{expected}" do + expect(payload[:organisme][:code]).to eq(expected) + end + end + end + end + + describe "france_travail_type_reception" do + context "when participation is collectif" do + before { motif.update(collectif: true) } + + it "returns COL" do + expect(payload[:typeReception]).to eq("COL") + end + end + + context "when participation is not collectif" do + it "returns IND" do + expect(payload[:typeReception]).to eq("IND") + end + end + end + + describe "france_travail_statut" do + { + "seen" => "EFFECTUE", + "excused" => "ANNULE", + "revoked" => "ANNULE", + "noshow" => "ABSENT", + "unknown" => "PRIS" + }.each do |status, expected| + context "when status is #{status}" do + before { participation.update(status: status) } + + it "returns #{expected}" do + expect(payload[:statut]).to eq(expected) + end + end + end + end +end diff --git a/spec/models/concerns/rdv/france_travail_webhooks_spec.rb b/spec/models/concerns/rdv/france_travail_webhooks_spec.rb deleted file mode 100644 index 124382f3c..000000000 --- a/spec/models/concerns/rdv/france_travail_webhooks_spec.rb +++ /dev/null @@ -1,79 +0,0 @@ -describe Rdv::FranceTravailWebhooks, type: :concern do - let!(:organisation) do - create(:organisation, safir_code: "123245", name: "CD de DIE") - end - let!(:rdv) do - create( - :rdv, - id: 330, motif:, agents: [agent], created_by: "agent", duration_in_min: 30, - starts_at:, lieu: create(:lieu, address: "105 Rue Camille Buffardel, Die, 26150"), - organisation:, participations: [participation] - ) - end - let!(:participation) { create(:participation, user:) } - - let!(:starts_at) { Time.zone.parse("26/03/2024 16:15") } - let!(:ends_at) { Time.zone.parse("26/03/2024 16:45") } - let!(:birth_date) { Time.zone.parse("20/12/1987") } - - let!(:now) { Time.zone.parse("22/03/2024 14:22") } - - let!(:motif) do - create( - :motif, - name: "RSA - Orientation : rdv sur site", collectif: false, location_type: "public_office", - instruction_for_rdv: "ramener pièce identité" - ) - end - let!(:agent) do - create(:agent, first_name: "Amine", last_name: "DHOBB", email: "amine.dhobb@beta.gouv.fr") - end - let!(:user) do - create(:user, last_name: "Kopke", first_name: "Andreas", title: "monsieur", phone_number: "+33664891033", - birth_date:, email: "andreas@kopke.com") - end - - let!(:france_travail_payload) do - { "idOrigine" => 330, - "libelleStructure" => "CD de DIE", - "codeSafir" => "123245", - "objet" => "RSA - Orientation : rdv sur site", - "nombrePlaces" => nil, - "idModalite" => 3, - "typeReception" => "Individuel", - "dateRendezvous" => starts_at.to_datetime, - "duree" => 30, - "initiateur" => "agent", - "address" => "105 Rue Camille Buffardel, Die, 26150", - "conseiller" => [{ "email" => "amine.dhobb@beta.gouv.fr", "nom" => "DHOBB", "prenom" => "Amine" }], - "participants" => [ - { "nir" => nil, "nom" => "Kopke", "prenom" => "Andreas", "civilite" => "monsieur", - "email" => "andreas@kopke.com", "telephone" => "+33664891033", "dateNaissance" => birth_date.to_date } - ], - "information" => "ramener pièce identité", - "dateAnnulation" => nil, - "dateFinRendezvous" => ends_at.to_datetime, - "mode" => "modification" } - end - - before do - allow(rdv).to receive(:updated_at).and_return(now) - allow(OutgoingWebhooks::SendFranceTravailWebhookJob).to receive(:perform_later) - end - - describe "#send_france_travail_webhook" do - it "enqueues the job" do - expect(OutgoingWebhooks::SendFranceTravailWebhookJob).to receive(:perform_later) - .with(france_travail_payload, now) - rdv.save - end - - context "when the organisation is not france travail" do - before { organisation.update! safir_code: nil } - - it "does not enqueue a job" do - expect(OutgoingWebhooks::SendFranceTravailWebhookJob).not_to receive(:perform_later) - end - end - end -end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index d5379bf72..9cb267c50 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -54,6 +54,7 @@ config.extend ApiSpecHelper config.include ApiSpecSharedExamples config.include ActiveSupport::Testing::TimeHelpers + config.include ActiveJob::TestHelper ## Clear downloads config.before(:each, :js) do diff --git a/spec/services/concerns/webhooks/receipt_handler_spec.rb b/spec/services/concerns/webhooks/receipt_handler_spec.rb new file mode 100644 index 000000000..56569b6e2 --- /dev/null +++ b/spec/services/concerns/webhooks/receipt_handler_spec.rb @@ -0,0 +1,67 @@ +describe Webhooks::ReceiptHandler do + let(:dummy_class) { Class.new { include Webhooks::ReceiptHandler } } + let(:instance) { dummy_class.new } + let(:webhook_endpoint) { create(:webhook_endpoint) } + + describe "#with_webhook_receipt" do + let(:resource_model) { "TestModel" } + let(:resource_id) { 1 } + let(:timestamp) { Time.current } + let(:webhook_endpoint_id) { webhook_endpoint.id } + + context "when no previous receipt exists" do + it "creates a new webhook receipt" do + expect do + instance.with_webhook_receipt( + resource_model: resource_model, + resource_id: resource_id, + timestamp: timestamp, + webhook_endpoint_id: webhook_endpoint_id + ) { true } + end.to change(WebhookReceipt, :count).by(1) + end + end + + context "when a previous receipt exists with an older timestamp" do + before do + create(:webhook_receipt, + resource_model: resource_model, + resource_id: resource_id, + timestamp: 1.hour.ago, + webhook_endpoint_id: webhook_endpoint_id) + end + + it "creates a new webhook receipt" do + expect do + instance.with_webhook_receipt( + resource_model: resource_model, + resource_id: resource_id, + timestamp: timestamp, + webhook_endpoint_id: webhook_endpoint_id + ) { true } + end.to change(WebhookReceipt, :count).by(1) + end + end + + context "when a previous receipt exists with a newer timestamp" do + before do + create(:webhook_receipt, + resource_model: resource_model, + resource_id: resource_id, + timestamp: 1.hour.from_now, + webhook_endpoint_id: webhook_endpoint_id) + end + + it "does not create a new webhook receipt" do + expect do + instance.with_webhook_receipt( + resource_model: resource_model, + resource_id: resource_id, + timestamp: timestamp, + webhook_endpoint_id: webhook_endpoint_id + ) { true } + end.not_to change(WebhookReceipt, :count) + end + end + end +end diff --git a/spec/services/france_travail_api/build_user_authenticated_headers_spec.rb b/spec/services/france_travail_api/build_user_authenticated_headers_spec.rb new file mode 100644 index 000000000..97476913e --- /dev/null +++ b/spec/services/france_travail_api/build_user_authenticated_headers_spec.rb @@ -0,0 +1,31 @@ +describe FranceTravailApi::BuildUserAuthenticatedHeaders, type: :service do + subject do + described_class.call(user: user) + end + + let(:user) { create(:user) } + let(:access_token) { "access-token" } + let(:user_token) { "user-token" } + + before do + allow(FranceTravailApi::RetrieveAccessToken).to receive(:call) + .and_return(OpenStruct.new(access_token: access_token, success?: true)) + allow(FranceTravailApi::RetrieveUserToken).to receive(:call) + .and_return(OpenStruct.new(user_token: user_token, success?: true)) + end + + describe "#call" do + it "returns headers with correct structure" do + subject + expect(subject.headers).to eq({ + "ft-jeton-usager" => user_token, + "Authorization" => "Bearer #{access_token}", + "Content-Type" => "application/json", + "Accept" => "application/json", + "pa-identifiant-agent" => "BATCH", + "pa-nom-agent" => "Webhooks Participation RDV-Insertion", + "pa-prenom-agent" => "Webhooks Participation RDV-Insertion" + }) + end + end +end diff --git a/spec/services/france_travail_api/create_participation_spec.rb b/spec/services/france_travail_api/create_participation_spec.rb new file mode 100644 index 000000000..0eb4d1466 --- /dev/null +++ b/spec/services/france_travail_api/create_participation_spec.rb @@ -0,0 +1,54 @@ +describe FranceTravailApi::CreateParticipation, type: :service do + subject do + described_class.call(participation_id: participation.id, timestamp: timestamp) + end + + let(:participation) { create(:participation) } + let(:timestamp) { Time.current } + let(:headers) do + { + "Authorization" => "Bearer token", + "Content-Type" => "application/json", + "Accept" => "application/json", + "ft-jeton-usager" => "jeton-usager", + "pa-identifiant-agent" => "BATCH", + "pa-nom-agent" => "Webhooks Participation RDV-Insertion", + "pa-prenom-agent" => "Webhooks Participation RDV-Insertion" + } + end + + before do + allow(FranceTravailClient).to receive(:create_participation) + .and_return(OpenStruct.new(success?: true, body: { id: "ft-123" }.to_json)) + allow(FranceTravailApi::BuildUserAuthenticatedHeaders).to receive(:call) + .and_return(OpenStruct.new(headers: headers, success?: true)) + end + + it "sends creation request to France Travail API" do + subject + expect(FranceTravailClient).to have_received(:create_participation) + end + + it "updates participation with France Travail ID" do + subject + expect(participation.reload.france_travail_id).to eq("ft-123") + end + + context "when API call fails" do + before do + allow(FranceTravailClient).to receive(:create_participation) + .and_return(OpenStruct.new(success?: false, status: 400, body: "Error")) + end + + it("is a failure") { is_a_failure } + + it "returns the error" do + expect(subject.errors).to eq( + [ + "Impossible d'appeler l'endpoint de l'api rendez-vous-partenaire FT (Création de Participation)." \ + "\nStatus: 400\n Body: Error" + ] + ) + end + end +end diff --git a/spec/services/france_travail_api/delete_participation_spec.rb b/spec/services/france_travail_api/delete_participation_spec.rb new file mode 100644 index 000000000..b1ff807d4 --- /dev/null +++ b/spec/services/france_travail_api/delete_participation_spec.rb @@ -0,0 +1,57 @@ +describe FranceTravailApi::DeleteParticipation, type: :service do + subject do + described_class.call( + participation_id: participation.id, + france_travail_id: france_travail_id, + user_id: user.id, + timestamp: timestamp + ) + end + + let!(:user) { create(:user, :with_valid_nir) } + let!(:rdv) { create(:rdv) } + let!(:participation) { create(:participation, rdv: rdv, user: user) } + let(:france_travail_id) { "ft-123" } + let(:timestamp) { Time.current } + let(:headers) do + { + "Authorization" => "Bearer token", + "Content-Type" => "application/json", + "Accept" => "application/json", + "ft-jeton-usager" => "jeton-usager", + "pa-identifiant-agent" => "BATCH", + "pa-nom-agent" => "Webhooks Participation RDV-Insertion", + "pa-prenom-agent" => "Webhooks Participation RDV-Insertion" + } + end + + before do + allow(FranceTravailClient).to receive(:delete_participation) + .and_return(OpenStruct.new(success?: true)) + allow(FranceTravailApi::BuildUserAuthenticatedHeaders).to receive(:call) + .and_return(OpenStruct.new(headers: headers, success?: true)) + end + + it "sends delete request to France Travail API" do + subject + expect(FranceTravailClient).to have_received(:delete_participation) + end + + context "when API call fails" do + before do + allow(FranceTravailClient).to receive(:delete_participation) + .and_return(OpenStruct.new(success?: false, status: 400, body: "Error")) + end + + it("is a failure") { is_a_failure } + + it "returns the error" do + expect(subject.errors).to eq( + [ + "Impossible d'appeler l'endpoint de l'api rendez-vous-partenaire FT (Suppression de Participation)." \ + "\nStatus: 400\n Body: Error" + ] + ) + end + end +end diff --git a/spec/services/retrieve_france_travail_access_token_spec.rb b/spec/services/france_travail_api/retrieve_access_token_spec.rb similarity index 91% rename from spec/services/retrieve_france_travail_access_token_spec.rb rename to spec/services/france_travail_api/retrieve_access_token_spec.rb index e663d3fee..c5f503568 100644 --- a/spec/services/retrieve_france_travail_access_token_spec.rb +++ b/spec/services/france_travail_api/retrieve_access_token_spec.rb @@ -1,18 +1,18 @@ -describe RetrieveFranceTravailAccessToken, type: :service do +describe FranceTravailApi::RetrieveAccessToken, type: :service do subject do described_class.call end - let!(:redis) { instance_double("redis") } + let!(:redis) { Redis.new } + let!(:access_token) { SecureRandom.uuid } describe "#call" do before do - allow(Redis).to receive(:new).and_return(redis) + allow(RedisConnection).to receive(:with_redis).and_yield(redis) allow(redis).to receive(:exists?) allow(redis).to receive(:get) allow(redis).to receive(:set) - allow(redis).to receive(:close) end context "when the token is already in redis" do diff --git a/spec/services/france_travail_api/retrieve_user_token_spec.rb b/spec/services/france_travail_api/retrieve_user_token_spec.rb new file mode 100644 index 000000000..79ad81e0d --- /dev/null +++ b/spec/services/france_travail_api/retrieve_user_token_spec.rb @@ -0,0 +1,66 @@ +describe FranceTravailApi::RetrieveUserToken do + subject do + described_class.call(user: user, access_token: access_token) + end + + let(:user) { create(:user, birth_date: "1990-01-01", nir: "1900167890123") } + let(:access_token) { "access-token" } + let(:france_travail_client) { FranceTravailClient } + let(:headers) do + { + "Authorization" => "Bearer #{access_token}", + "Content-Type" => "application/json", + "pa-identifiant-agent" => "BATCH", + "pa-nom-agent" => "Webhooks Participation RDV-Insertion", + "pa-prenom-agent" => "Webhooks Participation RDV-Insertion" + } + end + + before do + allow(FranceTravailApi::RetrieveAccessToken).to receive(:call) + .and_return(OpenStruct.new(access_token: access_token)) + end + + describe "#call" do + let(:expected_payload) do + { + dateNaissance: user.birth_date.to_s, + nir: user.nir + } + end + + context "when the API call is successful" do + let(:user_token) { "user-token-123" } + let(:response_body) { { "jetonUsager" => user_token, "codeRetour" => "S001" }.to_json } + + before do + allow(france_travail_client).to receive(:retrieve_user_token) + .with(payload: expected_payload, headers: headers) + .and_return(OpenStruct.new(success?: true, body: response_body)) + end + + it "returns the user token" do + subject + expect(subject.user_token).to eq(user_token) + end + end + + context "when the API call fails" do + let(:response_body) { { "jetonUsager" => nil, "codeRetour" => "R001" }.to_json } + + before do + allow(france_travail_client).to receive(:retrieve_user_token) + .with(payload: expected_payload, headers: headers) + .and_return(OpenStruct.new(success?: false, status: 400, body: response_body)) + end + + it("is a failure") { is_a_failure } + + it "returns the error" do + expect(subject.errors).to eq( + ["Erreur lors de l'appel à l'api recherche-usager FT.\nStatus: 400\n Body: #{response_body}"] + ) + end + end + end +end diff --git a/spec/services/france_travail_api/update_participation_spec.rb b/spec/services/france_travail_api/update_participation_spec.rb new file mode 100644 index 000000000..02b9776cf --- /dev/null +++ b/spec/services/france_travail_api/update_participation_spec.rb @@ -0,0 +1,49 @@ +describe FranceTravailApi::UpdateParticipation, type: :service do + subject do + described_class.call(participation_id: participation.id, timestamp: timestamp) + end + + let(:participation) { create(:participation) } + let(:timestamp) { Time.current } + let(:headers) do + { + "Authorization" => "Bearer token", + "Content-Type" => "application/json", + "Accept" => "application/json", + "ft-jeton-usager" => "jeton-usager", + "pa-identifiant-agent" => "BATCH", + "pa-nom-agent" => "Webhooks Participation RDV-Insertion", + "pa-prenom-agent" => "Webhooks Participation RDV-Insertion" + } + end + + before do + allow(FranceTravailClient).to receive(:update_participation) + .and_return(OpenStruct.new(success?: true)) + allow(FranceTravailApi::BuildUserAuthenticatedHeaders).to receive(:call) + .and_return(OpenStruct.new(headers: headers, success?: true)) + end + + it "sends update request to France Travail API" do + subject + expect(FranceTravailClient).to have_received(:update_participation) + end + + context "when API call fails" do + before do + allow(FranceTravailClient).to receive(:update_participation) + .and_return(OpenStruct.new(success?: false, status: 400, body: "Error")) + end + + it("is a failure") { is_a_failure } + + it "returns the error" do + expect(subject.errors).to eq( + [ + "Impossible d'appeler l'endpoint de l'api rendez-vous-partenaire FT (Update de Participation)." \ + "\nStatus: 400\n Body: Error" + ] + ) + end + end +end diff --git a/spec/services/outgoing_webhooks/send_france_travail_webhook_spec.rb b/spec/services/outgoing_webhooks/send_france_travail_webhook_spec.rb deleted file mode 100644 index 0f0fdcf1f..000000000 --- a/spec/services/outgoing_webhooks/send_france_travail_webhook_spec.rb +++ /dev/null @@ -1,100 +0,0 @@ -describe OutgoingWebhooks::SendFranceTravailWebhook, type: :service do - subject do - described_class.call(payload:, timestamp:) - end - - let!(:payload) do - { - "idOrigine" => 330, - "libelleStructure" => "CD de DIE", - "codeSafir" => "123245", - "objet" => "RSA - Orientation : rdv sur site" - } - end - let!(:access_token) { SecureRandom.uuid } - - let!(:timestamp) { Time.zone.parse("05/03/2024") } - - let!(:request_headers) do - { - "Authorization" => "Bearer #{access_token}", - "Content-Type" => "application/json" - } - end - - before do - allow(RetrieveFranceTravailAccessToken).to receive(:call) - .and_return(OpenStruct.new(success?: true, access_token:)) - allow(Faraday).to receive(:post).and_return(OpenStruct.new(success?: true)) - end - - it "is a success" do - is_a_success - end - - it "calls the france travail api" do - expect(Faraday).to receive(:post) - .with("https://francetravailfakerdvurl.fr", payload.to_json, request_headers) - subject - end - - it "creates a receipt" do - expect { subject }.to change(WebhookReceipt, :count).by(1) - webhook_receipt = WebhookReceipt.last - expect(webhook_receipt).to have_attributes(resource_model: "Rdv", timestamp:, resource_id: 330) - end - - context "when it is an old update" do - let!(:webhook_receipt) do - create( - :webhook_receipt, - webhook_endpoint_id: nil, resource_model: "Rdv", resource_id: 330, - timestamp: timestamp + 2.minutes - ) - end - - it "is a success" do - is_a_success - end - - it "does not call the ft api" do - expect(Faraday).not_to receive(:post) - subject - end - - it "does not create a receipt" do - expect { subject }.not_to change(WebhookReceipt, :count) - end - end - - context "when the api call fails" do - before do - allow(Faraday).to receive(:post).and_return(OpenStruct.new(success?: false, status: 400, - body: "something wrong happened")) - end - - it "is a failure" do - is_a_failure - end - - it "stores the error" do - expect(subject.errors).to contain_exactly("Impossible d'appeler l'endpoint de rdv FT.\n" \ - "Status: 400\n Body: something wrong happened") - end - end - - context "when the service retrieving the token fails" do - before do - allow(RetrieveFranceTravailAccessToken).to receive(:call) - .and_return(OpenStruct.new(success?: false, errors: ["could not retrieve token"])) - end - - it "is a failure" do - is_a_failure - end - - it "outputs the error" do - expect(subject.errors).to contain_exactly("could not retrieve token") - end - end -end