diff --git a/e2e/features/moderations/accept_blocking_moderation.feature b/e2e/features/moderations/accept_blocking_moderation.feature index 76899f66d..5ec3e79d9 100644 --- a/e2e/features/moderations/accept_blocking_moderation.feature +++ b/e2e/features/moderations/accept_blocking_moderation.feature @@ -6,23 +6,29 @@ Fonctionnalité: Accepter une modération bloquante avec la barre d'outils Et un faux serveur "identite.proconnect.gouv.fr" Quand je navigue sur la page Et je clique sur le bouton "ProConnect" + + Alors je dois voir le titre de page "Liste des moderations" Alors je vois "Liste des moderations" - Quand je vais à l'intérieur de la rangée nommée "Modération a traiter de Jean Bon pour 51935970700022" + Quand je vais à l'intérieur de la rangée nommée "Modération a traiter de Jean Bon pour 13002526500013" Et je clique sur "➡️" - Et je dois voir le titre de page "Modération a traiter de Jean Bon pour 51935970700022" Et je réinitialise le contexte + Et je dois voir le titre de page "Modération a traiter de Jean Bon pour 13002526500013" + Scénario: Le modérateur le valide avec la barre d'outils Quand je clique sur "✅ Accepter" - Alors je vois "A propos de jeanbon@yopmail.com pour l'organisation Abracadabra, je valide :" + Alors je vois "A propos de jeanbon@yopmail.com pour l'organisation DINUM, je valide :" Soit je vais à l'intérieur du dialogue nommé "la modale de validation" Quand je clique sur "Terminer" Et je réinitialise le contexte + Quand je clique sur "Annuler" + Et je vois "Cette modération a été marqué comme traitée le" Et je vois "Validé par user@yopmail.com" + Quand je clique sur "Moderations" Alors je vois "Liste des moderations" - Alors je ne vois pas "51935970700022" + Alors je ne vois pas "13002526500013" Alors une notification mail est envoyée diff --git a/e2e/features/moderations/validate_similar_moderations.feature b/e2e/features/moderations/validate_similar_moderations.feature new file mode 100644 index 000000000..3a58aaaf9 --- /dev/null +++ b/e2e/features/moderations/validate_similar_moderations.feature @@ -0,0 +1,51 @@ +#language: fr +Fonctionnalité: Validation automatique des modérations similaires + + Contexte: + Soit une base de données nourrie au grain + Et un faux serveur "identite.proconnect.gouv.fr" + Quand je navigue sur la page + Et je clique sur le bouton "ProConnect" + + Alors je dois voir le titre de page "Liste des moderations" + Alors je vois "Liste des moderations" + Quand je vais à l'intérieur de la rangée nommée "Modération a traiter de Jean Bon pour 51935970700022" + Et je clique sur "➡️" + Et je réinitialise le contexte + + Et je dois voir le titre de page "Modération a traiter de Jean Bon pour 51935970700022" + + Plan du Scénario: Validation automatique des modérations similaires avec domaine yopmail.com + Quand je clique sur "✅ Accepter" + Alors je vois "A propos de jeanbon@yopmail.com pour l'organisation Abracadabra, je valide :" + + Soit je vais à l'intérieur du dialogue nommé "la modale de validation" + Quand je clique sur "" + Quand je clique sur "J’autorise le domaine " + Quand je clique sur "Terminer" + Et je réinitialise le contexte + Quand je clique sur "Annuler" + + Alors je vois "Cette modération a été marqué comme traitée le" + Et je vois "Validé par user@yopmail.com" + + Quand je clique sur "Moderations" + Alors je dois voir le titre de page "Liste des moderations" + Et je clique sur "Voir les demandes traitées" + + Et je ne vois pas "Jean Bon" + Et je ne vois pas "Jean Dré" + + Quand je clique sur "Siret" + Et je tape "51935970700022" + Quand je vais à l'intérieur de la rangée nommée "Modération a traiter de Jean Dré pour 51935970700022" + Et je clique sur "✅" + Et je réinitialise le contexte + + Alors je dois voir le titre de page "Modération a traiter de Jean Dré pour 51935970700022" + Alors je vois "Validation automatique - " + + Exemples: + | add_member | add_domain | cause | + | EN TANT QU'INTERNE | yopmail.com en interne à l'organisation | domaine vérifié | + | EN TANT QU'EXTERNE | yopmail.com en externe à l'organisation | domaine externe vérifié | diff --git a/sources/infra/identite-proconnect/database/src/seed/insert.ts b/sources/infra/identite-proconnect/database/src/seed/insert.ts index 67a46f016..981f70e4b 100644 --- a/sources/infra/identite-proconnect/database/src/seed/insert.ts +++ b/sources/infra/identite-proconnect/database/src/seed/insert.ts @@ -14,6 +14,7 @@ import { insert_dinum } from "./organizations/dinum"; import { insert_sak } from "./organizations/sak"; import { insert_yes_we_hack } from "./organizations/yes_we_hack"; import { insert_jeanbon } from "./users/jeanbon"; +import { insert_jeandre } from "./users/jeandre"; import { insert_mariebon } from "./users/mariebon"; import { insert_pierrebon } from "./users/pierrebon"; import { insert_raphael } from "./users/raphael"; @@ -36,6 +37,10 @@ export async function insert_database(db: IdentiteProconnect_PgDatabase) { consola.verbose( `🌱 INSERT user ${jean_bon.given_name} ${jean_bon.family_name}`, ); + const jean_dre = await insert_jeandre(db); + consola.verbose( + `🌱 INSERT user ${jean_dre.given_name} ${jean_dre.family_name}`, + ); const pierre_bon = await insert_pierrebon(db); consola.verbose( `🌱 INSERT user ${pierre_bon.given_name} ${pierre_bon.family_name}`, @@ -111,6 +116,17 @@ export async function insert_database(db: IdentiteProconnect_PgDatabase) { `🌱 INSERT ${jean_bon.given_name} wants to join ${abracadabra.cached_libelle}`, ); + await insert_moderation(db, { + created_at: new Date("2011-11-11 00:03:15").toISOString(), + organization_id: abracadabra.id, + type: "organization_join_block" as MCP_Moderation["type"], + user_id: jean_dre.id, + ticket_id: "session_789", + }); + consola.verbose( + `🌱 INSERT ${jean_dre.given_name} wants to join ${abracadabra.cached_libelle}`, + ); + await insert_moderation(db, { organization_id: aldp.id, type: "big_organization_join" as MCP_Moderation["type"], diff --git a/sources/infra/identite-proconnect/database/src/seed/users/jeandre.ts b/sources/infra/identite-proconnect/database/src/seed/users/jeandre.ts new file mode 100644 index 000000000..c1938eb2a --- /dev/null +++ b/sources/infra/identite-proconnect/database/src/seed/users/jeandre.ts @@ -0,0 +1,21 @@ +// + +import type { IdentiteProconnect_PgDatabase } from "../.."; +import { schema } from "../../index"; + +// + +export async function insert_jeandre(db: IdentiteProconnect_PgDatabase) { + const insert = await db + .insert(schema.users) + .values({ + created_at: new Date("2024-01-15T10:30:00.000Z").toISOString(), + email: "jeandre@yopmail.com", + family_name: "Dré", + given_name: "Jean", + updated_at: new Date("2024-01-15T10:30:00.000Z").toISOString(), + }) + .returning(); + + return insert.at(0)!; +} diff --git a/sources/moderations/api/src/:id/$procedures/validate.e2e.test.ts b/sources/moderations/api/src/:id/$procedures/validate.e2e.test.ts index 2a90cd8ff..d4f514357 100644 --- a/sources/moderations/api/src/:id/$procedures/validate.e2e.test.ts +++ b/sources/moderations/api/src/:id/$procedures/validate.e2e.test.ts @@ -58,18 +58,19 @@ test("GET /moderation/:id/$procedures/validate { add_domain: true, add_member: A await pg.query.moderations.findFirst({ where: (table, { eq }) => eq(table.id, moderation_id), }), - ).toEqual({ - id: moderation_id, - organization_id: unicorn_organization_id, - user_id: adora_pony_user_id, - type: "", - ticket_id: null, - moderated_at: "2222-01-02 00:00:00+00", - moderated_by: "Anais Tailhade ", - comment: - "7952428800000 anais.tailhade@omage.gouv.fr | Validé par anais.tailhade@omage.gouv.fr", - created_at: "2222-01-01 00:00:00+00", - }); + ).toMatchInlineSnapshot(` + { + "comment": "7952428800000 anais.tailhade@omage.gouv.fr | Validé par anais.tailhade@omage.gouv.fr | Raison : "[ProConnect] ✨ Modeation validée"", + "created_at": "2222-01-01 00:00:00+00", + "id": 1, + "moderated_at": "2222-01-02 00:00:00+00", + "moderated_by": "Anais Tailhade ", + "organization_id": 1, + "ticket_id": null, + "type": "", + "user_id": 1, + } + `); expect( await pg.query.users_organizations.findFirst({ @@ -79,19 +80,21 @@ test("GET /moderation/:id/$procedures/validate { add_domain: true, add_member: A eq(table.user_id, adora_pony_user_id), ), }), - ).toEqual({ - created_at: "2222-01-02 00:00:00+00", - has_been_greeted: false, - is_external: false, - needs_official_contact_email_verification: false, - official_contact_email_verification_sent_at: null, - official_contact_email_verification_token: null, - organization_id: unicorn_organization_id, - updated_at: "2222-01-02 00:00:00+00", - user_id: adora_pony_user_id, - verification_type: "domain", - verified_at: "2222-01-02 00:00:00+00", - }); + ).toMatchInlineSnapshot(` + { + "created_at": "2222-01-02 00:00:00+00", + "has_been_greeted": false, + "is_external": false, + "needs_official_contact_email_verification": false, + "official_contact_email_verification_sent_at": null, + "official_contact_email_verification_token": null, + "organization_id": 1, + "updated_at": "2222-01-02 00:00:00+00", + "user_id": 1, + "verification_type": "domain", + "verified_at": "2222-01-02 00:00:00+00", + } + `); }); test("GET /moderation/:id/$procedures/validate { add_domain: false, add_member: AS_EXTERNAL }", async () => { @@ -122,18 +125,19 @@ test("GET /moderation/:id/$procedures/validate { add_domain: false, add_member: await pg.query.moderations.findFirst({ where: (table, { eq }) => eq(table.id, moderation_id), }), - ).toEqual({ - id: moderation_id, - organization_id: unicorn_organization_id, - user_id: adora_pony_user_id, - type: "", - ticket_id: null, - moderated_at: "2222-01-02 00:00:00+00", - moderated_by: "Anais Tailhade ", - comment: - "7952428800000 anais.tailhade@omage.gouv.fr | Validé par anais.tailhade@omage.gouv.fr", - created_at: "2222-01-01 00:00:00+00", - }); + ).toMatchInlineSnapshot(` + { + "comment": "7952428800000 anais.tailhade@omage.gouv.fr | Validé par anais.tailhade@omage.gouv.fr | Raison : "[ProConnect] ✨ Modeation validée"", + "created_at": "2222-01-01 00:00:00+00", + "id": 1, + "moderated_at": "2222-01-02 00:00:00+00", + "moderated_by": "Anais Tailhade ", + "organization_id": 1, + "ticket_id": null, + "type": "", + "user_id": 1, + } + `); expect( await pg.query.users_organizations.findFirst({ @@ -143,19 +147,21 @@ test("GET /moderation/:id/$procedures/validate { add_domain: false, add_member: eq(table.user_id, adora_pony_user_id), ), }), - ).toEqual({ - created_at: "2222-01-02 00:00:00+00", - has_been_greeted: false, - is_external: true, - needs_official_contact_email_verification: false, - official_contact_email_verification_sent_at: null, - official_contact_email_verification_token: null, - organization_id: unicorn_organization_id, - updated_at: "2222-01-02 00:00:00+00", - user_id: adora_pony_user_id, - verification_type: "domain", - verified_at: "2222-01-02 00:00:00+00", - }); + ).toMatchInlineSnapshot(` + { + "created_at": "2222-01-02 00:00:00+00", + "has_been_greeted": false, + "is_external": true, + "needs_official_contact_email_verification": false, + "official_contact_email_verification_sent_at": null, + "official_contact_email_verification_token": null, + "organization_id": 1, + "updated_at": "2222-01-02 00:00:00+00", + "user_id": 1, + "verification_type": "domain", + "verified_at": "2222-01-02 00:00:00+00", + } + `); }); async function given_moderation_42() { diff --git a/sources/moderations/api/src/:id/$procedures/validate.ts b/sources/moderations/api/src/:id/$procedures/validate.ts index 74bb1d563..1d79b7224 100644 --- a/sources/moderations/api/src/:id/$procedures/validate.ts +++ b/sources/moderations/api/src/:id/$procedures/validate.ts @@ -18,6 +18,7 @@ import { MemberJoinOrganization } from "@~/moderations.lib/usecase/member_join_o import { GetModerationById, GetModerationWithUser, + ValidateSimilarModerations, } from "@~/moderations.repository"; import { AddVerifiedDomain, @@ -44,6 +45,8 @@ export default new Hono().patch( const { id } = req.valid("param"); const { add_domain, add_member, send_notification, verification_type } = req.valid("form"); + + //#region 💉 Inject dependencies const add_verified_domain = AddVerifiedDomain({ get_organization_by_id: GetFicheOrganizationById({ pg: identite_pg }), mark_domain_as_verified: MarkDomainAsVerified(identite_pg_client), @@ -52,6 +55,9 @@ export default new Hono().patch( const update_user_by_id_in_organization = UpdateUserByIdInOrganization({ pg: identite_pg, }); + const validate_similar_moderations = + ValidateSimilarModerations(identite_pg); + //#endregion const [moderation_error, moderation] = await to( get_moderation_with_user(id), @@ -93,11 +99,18 @@ export default new Hono().patch( consola.error(domain_error); throw domain_error; }); + + await validate_similar_moderations({ + organization_id, + domain, + domain_verification_type: + add_member === "AS_INTERNAL" ? "verified" : "external", + userinfo, + }); } //#endregion //#region ✨ Member join organization - const is_external = match(add_member) .with("AS_INTERNAL", () => false) .with("AS_EXTERNAL", () => true) diff --git a/sources/moderations/lib/src/comment_message.ts b/sources/moderations/lib/src/comment_message.ts index e0788b9a0..06d1fde7f 100644 --- a/sources/moderations/lib/src/comment_message.ts +++ b/sources/moderations/lib/src/comment_message.ts @@ -9,6 +9,7 @@ const BUILTIN_COMMENT = z.object({ created_by: z.string().email(), }); const VALIDATED_COMMENT = BUILTIN_COMMENT.extend({ + reason: z.string().optional(), type: z.literal("VALIDATED"), }); const REJECTED_COMMENT = BUILTIN_COMMENT.extend({ @@ -28,7 +29,11 @@ export type Comment_Type = export function comment_message(comment_type: Comment_Type) { const comment_message = match(comment_type) - .with({ type: "VALIDATED" }, ({ created_by }) => `Validé par ${created_by}`) + .with({ type: "VALIDATED" }, ({ created_by, reason }) => + [`Validé par ${created_by}`, reason ? `Raison : "${reason}"` : false] + .filter(Boolean) + .join(" | "), + ) .with( { type: "REPROCESSED" }, ({ created_by }) => `Réouverte par ${created_by}`, diff --git a/sources/moderations/repository/src/ValidateSimilarModerations.test.ts b/sources/moderations/repository/src/ValidateSimilarModerations.test.ts new file mode 100644 index 000000000..4cfadaa0a --- /dev/null +++ b/sources/moderations/repository/src/ValidateSimilarModerations.test.ts @@ -0,0 +1,132 @@ +// + +import { anais_tailhade } from "@~/app.middleware/set_userinfo#fixture"; +import { schema } from "@~/identite-proconnect.database"; +import { + create_adora_pony_moderation, + create_adora_pony_user, + create_pink_diamond_user, + create_unicorn_organization, +} from "@~/identite-proconnect.database/seed/unicorn"; +import { + empty_database, + migrate, + pg, +} from "@~/identite-proconnect.database/testing"; +import { beforeAll, beforeEach, expect, setSystemTime, test } from "bun:test"; +import { ValidateSimilarModerations } from "./ValidateSimilarModerations"; + +// + +beforeAll(migrate); +beforeEach(empty_database); + +beforeAll(() => { + setSystemTime(new Date("2222-01-01T00:00:00.000Z")); +}); + +// + +test("validate similar moderations with verified domain", async () => { + const organization_id = await create_unicorn_organization(pg); + await create_adora_pony_user(pg); + await create_adora_pony_moderation(pg, { type: "1️⃣" }); + const pine_diamond_user_id = await create_pink_diamond_user(pg); + + await pg.insert(schema.moderations).values({ + organization_id, + user_id: pine_diamond_user_id, + type: "2️⃣", + }); + + const validate_similar_moderations = ValidateSimilarModerations(pg); + + const validated_moderations = await validate_similar_moderations({ + organization_id, + domain: "unicorn.xyz", + domain_verification_type: "verified", + userinfo: anais_tailhade, + }); + + const result = await pg.query.moderations.findMany({ + columns: { id: true, comment: true, user_id: true }, + where: (table, { inArray }) => inArray(table.id, validated_moderations), + }); + + expect(result).toMatchInlineSnapshot(` + [ + { + "comment": "7952342400000 anais.tailhade@omage.gouv.fr | Validé par anais.tailhade@omage.gouv.fr | Raison : "[ProConnect] ✨ Validation automatique - domaine vérifié"", + "id": 1, + "user_id": 1, + }, + { + "comment": "7952342400000 anais.tailhade@omage.gouv.fr | Validé par anais.tailhade@omage.gouv.fr | Raison : "[ProConnect] ✨ Validation automatique - domaine vérifié"", + "id": 2, + "user_id": 2, + }, + ] + `); +}); + +test("validate similar moderations with external domain", async () => { + const organization_id = await create_unicorn_organization(pg); + await create_adora_pony_user(pg); + await create_adora_pony_moderation(pg, { type: "1️⃣" }); + const pine_diamond_user_id = await create_pink_diamond_user(pg); + + await pg.insert(schema.moderations).values({ + organization_id, + user_id: pine_diamond_user_id, + type: "2️⃣", + }); + + const validate_similar_moderations = ValidateSimilarModerations(pg); + + const validated_moderations = await validate_similar_moderations({ + organization_id, + domain: "unicorn.xyz", + domain_verification_type: "external", + userinfo: anais_tailhade, + }); + + const result = await pg.query.moderations.findMany({ + columns: { id: true, comment: true, user_id: true }, + where: (table, { inArray }) => inArray(table.id, validated_moderations), + }); + + expect(result).toMatchInlineSnapshot(` + [ + { + "comment": "7952342400000 anais.tailhade@omage.gouv.fr | Validé par anais.tailhade@omage.gouv.fr | Raison : "[ProConnect] ✨ Validation automatique - domaine externe vérifié"", + "id": 1, + "user_id": 1, + }, + { + "comment": "7952342400000 anais.tailhade@omage.gouv.fr | Validé par anais.tailhade@omage.gouv.fr | Raison : "[ProConnect] ✨ Validation automatique - domaine externe vérifié"", + "id": 2, + "user_id": 2, + }, + ] + `); +}); + +test("no validation when domain is not verified", async () => { + const organization_id = await create_unicorn_organization(pg); + + const validate_similar_moderations = ValidateSimilarModerations(pg); + + const validated_moderations = await validate_similar_moderations({ + organization_id, + domain: "unverified.com", + domain_verification_type: "external", + userinfo: anais_tailhade, + }); + + const result = await pg.query.moderations.findMany({ + columns: { id: true, comment: true, user_id: true }, + where: (table, { inArray }) => inArray(table.id, validated_moderations), + }); + + expect(result).toMatchInlineSnapshot(`[]`); +}); diff --git a/sources/moderations/repository/src/ValidateSimilarModerations.ts b/sources/moderations/repository/src/ValidateSimilarModerations.ts new file mode 100644 index 000000000..9b6e80e74 --- /dev/null +++ b/sources/moderations/repository/src/ValidateSimilarModerations.ts @@ -0,0 +1,79 @@ +// + +import type { AgentConnect_UserInfo } from "@~/app.middleware/session"; +import { + schema, + type IdentiteProconnect_PgDatabase, +} from "@~/identite-proconnect.database"; +import { mark_moderation_as } from "@~/moderations.lib/usecase/mark_moderation_as"; +import { and, eq, ilike, isNull } from "drizzle-orm"; + +// + +export function ValidateSimilarModerations(pg: IdentiteProconnect_PgDatabase) { + return async function validate_similar_moderations({ + domain_verification_type, + domain, + organization_id, + userinfo, + }: { + domain_verification_type: "verified" | "external"; + domain: string; + organization_id: number; + userinfo: AgentConnect_UserInfo; + }) { + // Auto-validate the matching moderations following PCI rules + const reason = + domain_verification_type === "verified" + ? "[ProConnect] ✨ Validation automatique - domaine vérifié" + : "[ProConnect] ✨ Validation automatique - domaine externe vérifié"; + + // Find all pending moderations with matching domain using modern query builder + const matching_moderations = await pg + .select({ + comment: schema.moderations.comment, + id: schema.moderations.id, + user_id: schema.moderations.user_id, + user_email: schema.users.email, + }) + .from(schema.moderations) + .innerJoin(schema.users, eq(schema.moderations.user_id, schema.users.id)) + .where( + and( + eq(schema.moderations.organization_id, organization_id), + isNull(schema.moderations.moderated_at), + ilike(schema.users.email, `%@${domain}`), + ), + ); + + // Early return for empty results + if (!matching_moderations.length) return []; + + // Atomic batch validation using transaction and Promise.all + return pg.transaction(async (tx) => { + const validation_promises = matching_moderations.map( + async (moderation) => { + await mark_moderation_as( + { + moderation: { comment: moderation.comment, id: moderation.id }, + pg: tx, + reason, + userinfo, + }, + "VALIDATED", + ); + return moderation.id; + }, + ); + + return Promise.all(validation_promises); + }); + }; +} + +export type ValidateSimilarModerationsHandler = ReturnType< + typeof ValidateSimilarModerations +>; +export type ValidateSimilarModerationsDto = Awaited< + ReturnType +>; diff --git a/sources/moderations/repository/src/index.ts b/sources/moderations/repository/src/index.ts index 21c45bcdb..91f36f3d2 100644 --- a/sources/moderations/repository/src/index.ts +++ b/sources/moderations/repository/src/index.ts @@ -9,3 +9,4 @@ export * from "./GetModerationWithDetails"; export * from "./GetModerationWithUser"; export * from "./RemoveUserFromOrganization"; export * from "./UpdateModerationById"; +export * from "./ValidateSimilarModerations"; diff --git a/sources/moderations/ui/src/DomainsByOrganization/DomainsByOrganization.tsx b/sources/moderations/ui/src/DomainsByOrganization/DomainsByOrganization.tsx index e8ecda414..85b921276 100644 --- a/sources/moderations/ui/src/DomainsByOrganization/DomainsByOrganization.tsx +++ b/sources/moderations/ui/src/DomainsByOrganization/DomainsByOrganization.tsx @@ -43,7 +43,7 @@ export async function DomainsByOrganization(props: Props) {
diff --git a/sources/moderations/ui/src/UsersByOrganization/UsersByOrganization.tsx b/sources/moderations/ui/src/UsersByOrganization/UsersByOrganization.tsx index 95aac8fa8..9801903b4 100644 --- a/sources/moderations/ui/src/UsersByOrganization/UsersByOrganization.tsx +++ b/sources/moderations/ui/src/UsersByOrganization/UsersByOrganization.tsx @@ -54,7 +54,7 @@ export async function UsersByOrganization(props: Props) { hx-include={hx_include([$page_ref])} hx-target="this" hx-trigger={[ - "load", + "load delay:1s", hx_trigger_from_body([ORGANISATION_EVENTS.Enum.MEMBERS_UPDATED]), ].join(", ")} >