diff --git a/packages/identite/src/managers/organization/index.ts b/packages/identite/src/managers/organization/index.ts index 1106d2fcd..8612c91d1 100644 --- a/packages/identite/src/managers/organization/index.ts +++ b/packages/identite/src/managers/organization/index.ts @@ -2,4 +2,5 @@ export * from "./force-join-organization.js"; export * from "./get-organization-info.js"; +export * from "./join-organization.js"; export * from "./mark-domain-as-verified.js"; diff --git a/packages/identite/src/managers/organization/join-organization.test.ts b/packages/identite/src/managers/organization/join-organization.test.ts new file mode 100644 index 000000000..e1d1607fe --- /dev/null +++ b/packages/identite/src/managers/organization/join-organization.test.ts @@ -0,0 +1,517 @@ +import type { EmailDomain, Organization } from "#src/types"; +import assert from "node:assert/strict"; +import { suite, test } from "node:test"; +import { joinOrganization, type JoinContext } from "./join-organization.js"; + +function createContext(overrides: Partial = {}): JoinContext { + const defaultOrganization: Partial = { + cached_est_active: true, + cached_libelle_categorie_juridique: "Société à responsabilité limitée", + cached_tranche_effectifs: "12", + siret: "12345678901234", + }; + + return { + contactEmail: null, + domain: "example.com", + featureBypassModeration: false, + isContactDomainFree: true, + isContactEmailSameDomain: false, + isContactEmailValid: false, + isFreeEmailProvider: false, + organization: { + ...defaultOrganization, + ...overrides.organization, + } as Organization, + organizationEmailDomains: [], + userEmail: "user@example.com", + userHasConfirmed: false, + ...overrides, + }; +} + +suite("joinOrganization", () => { + suite("organization-only checks", () => { + test("returns error when organization is not active", () => { + const result = joinOrganization( + createContext({ + organization: { cached_est_active: false } as Organization, + }), + ); + + assert.deepEqual(result, { + type: "error", + reason: "organization_not_active", + }); + }); + + test("returns link for entreprise unipersonnelle", () => { + const result = joinOrganization( + createContext({ + organization: { + cached_est_active: true, + cached_libelle_categorie_juridique: "Entrepreneur individuel", + cached_tranche_effectifs_unite_legale: "00", + } as Organization, + }), + ); + + assert.deepEqual(result, { + type: "link", + verification_type: + "no_verification_means_for_entreprise_unipersonnelle", + }); + }); + + test("returns link for small association", () => { + const result = joinOrganization( + createContext({ + organization: { + cached_est_active: true, + cached_libelle_categorie_juridique: "Association déclarée", + cached_tranche_effectifs: "12", + } as Organization, + }), + ); + + assert.deepEqual(result, { + type: "link", + verification_type: "no_verification_means_for_small_association", + }); + }); + }); + + suite("domain validation checks", () => { + test("returns error when domain is not allowed for organization", () => { + const result = joinOrganization( + createContext({ + organization: { + cached_est_active: true, + siret: "11000201100044", + } as Organization, + domain: "unauthorized.com", + }), + ); + + assert.deepEqual(result, { + type: "error", + reason: "domain_not_allowed", + }); + }); + + test("returns error when domain is explicitly refused", () => { + const result = joinOrganization( + createContext({ + domain: "refused.com", + organizationEmailDomains: [ + { + domain: "refused.com", + verification_type: "refused", + } as EmailDomain, + ], + }), + ); + + assert.deepEqual(result, { + type: "error", + reason: "domain_refused", + }); + }); + + test("returns error when using gouv.fr domain for private organization", () => { + const result = joinOrganization( + createContext({ + organization: { + cached_est_active: true, + cached_categorie_juridique: "5710", + } as Organization, + domain: "example.gouv.fr", + }), + ); + + assert.deepEqual(result, { + type: "error", + reason: "gouv_fr_domain_forbidden_for_private_org", + }); + }); + + test("allows gouv.fr domain for public service", () => { + const result = joinOrganization( + createContext({ + organization: { + cached_est_active: true, + cached_categorie_juridique: "7120", + cached_etat_administratif: "A", + } as Organization, + domain: "example.gouv.fr", + }), + ); + + assert.notEqual(result.type, "error"); + }); + }); + + suite("public service email requirements", () => { + test("returns error when public service user uses free email", () => { + const result = joinOrganization( + createContext({ + organization: { + cached_est_active: true, + cached_categorie_juridique: "7120", + cached_etat_administratif: "A", + } as Organization, + isFreeEmailProvider: true, + }), + ); + + assert.deepEqual(result, { + type: "error", + reason: "public_service_requires_professional_email", + }); + }); + + test("allows free email for syndicat communal", () => { + const result = joinOrganization( + createContext({ + organization: { + cached_est_active: true, + cached_categorie_juridique: "7120", + cached_etat_administratif: "A", + cached_libelle_categorie_juridique: + "Syndicat intercommunal à vocation unique (SIVU)", + } as Organization, + isFreeEmailProvider: true, + }), + ); + + if (result.type === "error") { + assert.notEqual( + result.reason, + "public_service_requires_professional_email", + ); + } + }); + }); + + suite("large organization confirmation", () => { + test("returns needs_confirmation for large organization with free email", () => { + const result = joinOrganization( + createContext({ + organization: { + cached_est_active: true, + cached_tranche_effectifs: "21", + } as Organization, + isFreeEmailProvider: true, + userHasConfirmed: false, + }), + ); + + assert.deepEqual(result, { + type: "needs_confirmation", + }); + }); + + test("proceeds when user has confirmed", () => { + const result = joinOrganization( + createContext({ + organization: { + cached_est_active: true, + cached_tranche_effectifs: "21", + } as Organization, + isFreeEmailProvider: true, + userHasConfirmed: true, + }), + ); + + assert.notEqual(result.type, "needs_confirmation"); + }); + + test("skips confirmation for small organization", () => { + const result = joinOrganization( + createContext({ + organization: { + cached_est_active: true, + cached_tranche_effectifs: "12", + } as Organization, + isFreeEmailProvider: true, + }), + ); + + assert.notEqual(result.type, "needs_confirmation"); + }); + }); + + suite("commune contact email verification", () => { + const communeOrganization = { + cached_est_active: true, + cached_libelle_categorie_juridique: "Commune et commune nouvelle", + cached_etat_administratif: "A", + cached_libelle_activite_principale: + "84.11Z - Administration publique générale", + cached_tranche_effectifs: null, + } as Organization; + + test("links with official_contact_email when user email matches contact", () => { + const result = joinOrganization( + createContext({ + organization: communeOrganization, + userEmail: "mairie@commune.fr", + domain: "commune.fr", + contactEmail: "mairie@commune.fr", + isContactEmailSameDomain: true, + isContactEmailValid: true, + isContactDomainFree: false, + }), + ); + + assert.deepEqual(result, { + type: "link", + verification_type: "official_contact_email", + should_mark_contact_domain_verified: true, + }); + }); + + test("links with domain when user domain matches contact domain", () => { + const result = joinOrganization( + createContext({ + organization: communeOrganization, + userEmail: "autre@commune.fr", + domain: "commune.fr", + contactEmail: "mairie@commune.fr", + isContactEmailSameDomain: true, + isContactEmailValid: true, + isContactDomainFree: false, + }), + ); + + assert.deepEqual(result, { + type: "link", + verification_type: "domain", + should_mark_contact_domain_verified: true, + }); + }); + + test("sends code to official contact when domains differ", () => { + const result = joinOrganization( + createContext({ + organization: communeOrganization, + userEmail: "user@gmail.com", + domain: "gmail.com", + isFreeEmailProvider: true, + contactEmail: "mairie@commune.fr", + isContactEmailSameDomain: false, + isContactEmailValid: true, + isContactDomainFree: false, + }), + ); + + assert.deepEqual(result, { + type: "link", + verification_type: "code_sent_to_official_contact_email", + needs_official_contact_email_verification: true, + should_mark_contact_domain_verified: true, + }); + }); + + test("does not mark contact domain verified if free email provider", () => { + const result = joinOrganization( + createContext({ + organization: communeOrganization, + userEmail: "mairie@gmail.com", + domain: "gmail.com", + isFreeEmailProvider: true, + contactEmail: "mairie@gmail.com", + isContactEmailSameDomain: true, + isContactEmailValid: true, + isContactDomainFree: true, + }), + ); + + assert.deepEqual(result, { + type: "link", + verification_type: "official_contact_email", + should_mark_contact_domain_verified: false, + }); + }); + }); + + suite("school contact email verification", () => { + const schoolOrganization = { + cached_est_active: true, + cached_libelle_categorie_juridique: + "Établissement public local d'enseignement", + cached_etat_administratif: "A", + cached_libelle_activite_principale: + "85.31Z - Enseignement secondaire général", + cached_tranche_effectifs: null, + } as Organization; + + test("links with official_contact_email when user email matches contact", () => { + const result = joinOrganization( + createContext({ + organization: schoolOrganization, + userEmail: "directeur@ac-paris.fr", + domain: "ac-paris.fr", + contactEmail: "directeur@ac-paris.fr", + isContactEmailSameDomain: true, + isContactEmailValid: true, + }), + ); + + assert.deepEqual(result, { + type: "link", + verification_type: "official_contact_email", + should_mark_contact_domain_verified: false, + }); + }); + + test("sends code to official contact when emails differ", () => { + const result = joinOrganization( + createContext({ + organization: schoolOrganization, + userEmail: "prof@ac-paris.fr", + domain: "ac-paris.fr", + contactEmail: "directeur@ac-paris.fr", + isContactEmailSameDomain: true, + isContactEmailValid: true, + }), + ); + + assert.deepEqual(result, { + type: "link", + verification_type: "code_sent_to_official_contact_email", + needs_official_contact_email_verification: true, + }); + }); + + test("falls through to domain verification when no valid contact email", () => { + const result = joinOrganization( + createContext({ + organization: schoolOrganization, + userEmail: "prof@ac-paris.fr", + domain: "ac-paris.fr", + contactEmail: null, + isContactEmailValid: false, + organizationEmailDomains: [ + { + domain: "ac-paris.fr", + verification_type: "verified", + } as EmailDomain, + ], + }), + ); + + assert.deepEqual(result, { + type: "link", + verification_type: "domain", + }); + }); + }); + + suite("domain-based verification", () => { + test("links with domain when domain is verified", () => { + const result = joinOrganization( + createContext({ + domain: "verified.com", + organizationEmailDomains: [ + { + domain: "verified.com", + verification_type: "verified", + } as EmailDomain, + ], + }), + ); + + assert.deepEqual(result, { + type: "link", + verification_type: "domain", + }); + }); + + test("links with domain and is_external when domain is external", () => { + const result = joinOrganization( + createContext({ + domain: "ext.company.com", + organizationEmailDomains: [ + { + domain: "ext.company.com", + verification_type: "external", + } as EmailDomain, + ], + }), + ); + + assert.deepEqual(result, { + type: "link", + verification_type: "domain", + is_external: true, + }); + }); + + test("links with domain when domain is trackdechets verified", () => { + const result = joinOrganization( + createContext({ + domain: "trackdechets.com", + organizationEmailDomains: [ + { + domain: "trackdechets.com", + verification_type: "trackdechets_postal_mail", + } as EmailDomain, + ], + }), + ); + + assert.deepEqual(result, { + type: "link", + verification_type: "domain", + }); + }); + }); + + suite("fallback paths", () => { + test("links with bypassed when featureBypassModeration is true", () => { + const result = joinOrganization( + createContext({ + featureBypassModeration: true, + }), + ); + + assert.deepEqual(result, { + type: "link", + verification_type: "bypassed", + }); + }); + + test("returns moderation_required when domain exists but not verified", () => { + const result = joinOrganization( + createContext({ + domain: "unverified.com", + organizationEmailDomains: [ + { + domain: "unverified.com", + verification_type: null, + } as EmailDomain, + ], + }), + ); + + assert.deepEqual(result, { + type: "moderation_required", + moderation_type: "non_verified_domain", + }); + }); + + test("returns unable_to_auto_join when no path available", () => { + const result = joinOrganization( + createContext({ + domain: "unknown.com", + organizationEmailDomains: [], + }), + ); + + assert.deepEqual(result, { + type: "unable_to_auto_join", + }); + }); + }); +}); diff --git a/packages/identite/src/managers/organization/join-organization.ts b/packages/identite/src/managers/organization/join-organization.ts new file mode 100644 index 000000000..84bb960cd --- /dev/null +++ b/packages/identite/src/managers/organization/join-organization.ts @@ -0,0 +1,182 @@ +// + +import { + hasLessThanFiftyEmployees, + isCommune, + isDomainAllowedForOrganization, + isEntrepriseUnipersonnelle, + isEtablissementScolaireDuPremierEtSecondDegre, + isPublicService, + isSmallAssociation, + isSyndicatCommunal, +} from "#src/services/organization"; +import type { EmailDomain, Organization } from "#src/types"; + +// + +export type JoinErrorReason = + | "domain_not_allowed" + | "domain_refused" + | "gouv_fr_domain_forbidden_for_private_org" + | "organization_not_active" + | "public_service_requires_professional_email"; + +type LinkDecision = { + type: "link"; + verification_type: string; + is_external?: boolean; + needs_official_contact_email_verification?: boolean; + should_mark_contact_domain_verified?: boolean; +}; + +export type JoinDecision = + | { type: "error"; reason: JoinErrorReason } + | LinkDecision + | { type: "needs_confirmation" } + | { type: "moderation_required"; moderation_type: "non_verified_domain" } + | { type: "unable_to_auto_join" }; + +export interface JoinContext { + contactEmail: string | null; + domain: string; + featureBypassModeration: boolean; + isContactDomainFree: boolean; + isContactEmailSameDomain: boolean; + isContactEmailValid: boolean; + isFreeEmailProvider: boolean; + organization: Organization; + organizationEmailDomains: EmailDomain[]; + userEmail: string; + userHasConfirmed: boolean; +} + +// + +const link = ( + verification_type: string, + options?: Partial>, +): LinkDecision => ({ type: "link", verification_type, ...options }); + +const error = (reason: JoinErrorReason) => ({ type: "error", reason }) as const; + +// + +export function joinOrganization(ctx: JoinContext): JoinDecision { + const { domain, organization, organizationEmailDomains } = ctx; + + const domainVerificationType = organizationEmailDomains.find( + (d) => d.domain === domain, + )?.verification_type; + + if (!organization.cached_est_active) { + return error("organization_not_active"); + } + + if (!isDomainAllowedForOrganization(organization.siret, domain)) { + return error("domain_not_allowed"); + } + + if (domainVerificationType === "refused") { + return error("domain_refused"); + } + + if (domain.endsWith("gouv.fr") && !isPublicService(organization)) { + return error("gouv_fr_domain_forbidden_for_private_org"); + } + + if (isEntrepriseUnipersonnelle(organization)) { + return link("no_verification_means_for_entreprise_unipersonnelle"); + } + + if (isSmallAssociation(organization)) { + return link("no_verification_means_for_small_association"); + } + + const allowsFreeEmail = + isCommune(organization, true) || isSyndicatCommunal(organization); + + if ( + ctx.isFreeEmailProvider && + isPublicService(organization) && + !allowsFreeEmail + ) { + return error("public_service_requires_professional_email"); + } + + const needsConfirmation = + ctx.isFreeEmailProvider && + !hasLessThanFiftyEmployees(organization) && + !ctx.userHasConfirmed && + !isSyndicatCommunal(organization); + + if (needsConfirmation) { + return { type: "needs_confirmation" }; + } + + const officialContactDecision = tryOfficialContactVerification(ctx); + if (officialContactDecision) { + return officialContactDecision; + } + + if (domainVerificationType === "verified") { + return link("domain"); + } + + if (domainVerificationType === "external") { + return link("domain", { is_external: true }); + } + + if (domainVerificationType === "trackdechets_postal_mail") { + return link("domain"); + } + + if (ctx.featureBypassModeration) { + return link("bypassed"); + } + + if (domainVerificationType === null) { + return { + type: "moderation_required", + moderation_type: "non_verified_domain", + }; + } + + return { type: "unable_to_auto_join" }; +} + +function tryOfficialContactVerification(ctx: JoinContext): LinkDecision | null { + const { organization, contactEmail, userEmail, isContactEmailValid } = ctx; + + const isSchool = isEtablissementScolaireDuPremierEtSecondDegre(organization); + const isCommuneNotSchool = isCommune(organization) && !isSchool; + + if (!isCommuneNotSchool && !isSchool) { + return null; + } + + const markDomainVerified = isCommuneNotSchool && !ctx.isContactDomainFree; + + if (contactEmail === userEmail) { + return link("official_contact_email", { + should_mark_contact_domain_verified: markDomainVerified, + }); + } + + if (isCommuneNotSchool && isContactEmailValid) { + if (ctx.isContactEmailSameDomain && !ctx.isContactDomainFree) { + return link("domain", { should_mark_contact_domain_verified: true }); + } + return link("code_sent_to_official_contact_email", { + needs_official_contact_email_verification: true, + should_mark_contact_domain_verified: markDomainVerified, + }); + } + + if (isSchool && isContactEmailValid) { + return link("code_sent_to_official_contact_email", { + needs_official_contact_email_verification: true, + }); + } + + return null; +} diff --git a/packages/identite/src/services/organization/get-organization-type-label.test.ts b/packages/identite/src/services/organization/get-organization-type-label.test.ts new file mode 100644 index 000000000..506b322cc --- /dev/null +++ b/packages/identite/src/services/organization/get-organization-type-label.test.ts @@ -0,0 +1,61 @@ +// + +import type { Organization } from "#src/types"; +import assert from "node:assert/strict"; +import { suite, test } from "node:test"; +import { getOrganizationTypeLabel } from "./get-organization-type-label.js"; + +// + +suite("getOrganizationTypeLabel", () => { + test("returns 'établissement scolaire' for school", () => { + const organization = { + cached_libelle_activite_principale: + "85.31Z - Enseignement secondaire général", + cached_libelle_categorie_juridique: + "Établissement public local d'enseignement", + } as Organization; + + assert.equal( + getOrganizationTypeLabel(organization), + "établissement scolaire", + ); + }); + + test("returns 'mairie' for commune", () => { + const organization = { + cached_categorie_juridique: "7210", + cached_libelle_categorie_juridique: "Commune et commune nouvelle", + } as Organization; + + assert.equal(getOrganizationTypeLabel(organization), "mairie"); + }); + + test("returns 'service' for public service that is not commune", () => { + const organization = { + cached_categorie_juridique: "7120", + cached_libelle_categorie_juridique: "Service central d'un ministère", + } as Organization; + + assert.equal(getOrganizationTypeLabel(organization), "service"); + }); + + test("returns 'entreprise' for entreprise unipersonnelle with waste management", () => { + const organization = { + cached_libelle_categorie_juridique: "Entrepreneur individuel", + cached_tranche_effectifs_unite_legale: "00", + cached_libelle_activite_principale: + "38.21Z - Traitement et élimination des déchets non dangereux", + } as Organization; + + assert.equal(getOrganizationTypeLabel(organization), "entreprise"); + }); + + test("returns 'organisation' by default", () => { + const organization = { + cached_libelle_categorie_juridique: "Association déclarée", + } as Organization; + + assert.equal(getOrganizationTypeLabel(organization), "organisation"); + }); +}); diff --git a/packages/identite/src/services/organization/get-organization-type-label.ts b/packages/identite/src/services/organization/get-organization-type-label.ts new file mode 100644 index 000000000..26a588d34 --- /dev/null +++ b/packages/identite/src/services/organization/get-organization-type-label.ts @@ -0,0 +1,33 @@ +// + +import type { Organization } from "#src/types"; +import { isCommune } from "./is-commune.js"; +import { isEntrepriseUnipersonnelle } from "./is-entreprise-unipersonnelle.js"; +import { isEtablissementScolaireDuPremierEtSecondDegre } from "./is-etablissement-scolaire-du-premier-et-second-degre.js"; +import { isPublicService } from "./is-public-service.js"; +import { isWasteManagementOrganization } from "./is-waste-management-organization.js"; + +// + +export function getOrganizationTypeLabel(organization: Organization): string { + if (isEtablissementScolaireDuPremierEtSecondDegre(organization)) { + return "établissement scolaire"; + } else { + if (isCommune(organization)) { + return "mairie"; + } + + if (isPublicService(organization)) { + return "service"; + } + } + + if ( + isEntrepriseUnipersonnelle(organization) && + isWasteManagementOrganization(organization) + ) { + return "entreprise"; + } + + return "organisation"; +} diff --git a/packages/identite/src/services/organization/has-less-than-fifty-employees.test.ts b/packages/identite/src/services/organization/has-less-than-fifty-employees.test.ts new file mode 100644 index 000000000..bb0ea6993 --- /dev/null +++ b/packages/identite/src/services/organization/has-less-than-fifty-employees.test.ts @@ -0,0 +1,42 @@ +// + +import { hasLessThanFiftyEmployees } from "#src/services/organization"; +import type { Organization } from "#src/types"; +import { + association_org_info, + dinum_org_info, + entreprise_unipersonnelle_org_info, + lamalou_org_info, + trackdechets_public_org_info, +} from "#testing/seed/organizations"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +describe("hasLessThanFiftyEmployees", () => { + it("should return true for organization with null tranche effectifs", () => { + assert.equal( + hasLessThanFiftyEmployees(entreprise_unipersonnelle_org_info), + true, + ); + }); + + it("should return true for organization with NN tranche effectifs", () => { + assert.equal(hasLessThanFiftyEmployees(trackdechets_public_org_info), true); + }); + + it("should return true for organization with 00 tranche effectifs", () => { + assert.equal(hasLessThanFiftyEmployees(association_org_info), true); + }); + + it("should return true for organization with 12 tranche effectifs (20-49 employees)", () => { + assert.equal(hasLessThanFiftyEmployees(lamalou_org_info), true); + }); + + it("should return false for organization with 22 tranche effectifs (100-199 employees)", () => { + assert.equal(hasLessThanFiftyEmployees(dinum_org_info), false); + }); + + it("should return false for organization with undefined tranche effectifs", () => { + assert.equal(hasLessThanFiftyEmployees({} as Organization), false); + }); +}); diff --git a/packages/identite/src/services/organization/has-less-than-fifty-employees.ts b/packages/identite/src/services/organization/has-less-than-fifty-employees.ts new file mode 100644 index 000000000..7e8a8600d --- /dev/null +++ b/packages/identite/src/services/organization/has-less-than-fifty-employees.ts @@ -0,0 +1,22 @@ +// + +import type { Organization } from "#src/types"; + +// + +const SMALL_TRANCHE_EFFECTIFS = [ + null, + "NN", + "00", + "01", + "02", + "03", + "11", + "12", +]; + +export function hasLessThanFiftyEmployees({ + cached_tranche_effectifs, +}: Pick): boolean { + return SMALL_TRANCHE_EFFECTIFS.includes(cached_tranche_effectifs); +} diff --git a/packages/identite/src/services/organization/index.ts b/packages/identite/src/services/organization/index.ts index 5942d5998..ef8deda96 100644 --- a/packages/identite/src/services/organization/index.ts +++ b/packages/identite/src/services/organization/index.ts @@ -1,7 +1,15 @@ // +export * from "./get-organization-type-label.js"; +export * from "./has-less-than-fifty-employees.js"; +export * from "./is-armee-domain.js"; +export * from "./is-commune.js"; export * from "./is-domain-allowed-for-organization.js"; +export * from "./is-education-nationale-domain.js"; export * from "./is-entreprise-unipersonnelle.js"; +export * from "./is-etablissement-scolaire-du-premier-et-second-degre.js"; export * from "./is-organization-covered-by-certification-dirigeant.js"; export * from "./is-public-service.js"; +export * from "./is-small-association.js"; export * from "./is-syndicat-communal.js"; +export * from "./is-waste-management-organization.js"; diff --git a/packages/identite/src/services/organization/is-armee-domain.test.ts b/packages/identite/src/services/organization/is-armee-domain.test.ts new file mode 100644 index 000000000..f66af8ed5 --- /dev/null +++ b/packages/identite/src/services/organization/is-armee-domain.test.ts @@ -0,0 +1,27 @@ +// + +import { isArmeeDomain } from "#src/services/organization"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +describe("isArmeeDomain", () => { + it("should return true for intradef.gouv.fr", () => { + assert.equal(isArmeeDomain("intradef.gouv.fr"), true); + }); + + it("should return true for def.gouv.fr", () => { + assert.equal(isArmeeDomain("def.gouv.fr"), true); + }); + + it("should return false for regular gouv.fr domain", () => { + assert.equal(isArmeeDomain("gouv.fr"), false); + }); + + it("should return false for random domain", () => { + assert.equal(isArmeeDomain("example.com"), false); + }); + + it("should return false for empty domain", () => { + assert.equal(isArmeeDomain(""), false); + }); +}); diff --git a/packages/identite/src/services/organization/is-armee-domain.ts b/packages/identite/src/services/organization/is-armee-domain.ts new file mode 100644 index 000000000..1e4143c54 --- /dev/null +++ b/packages/identite/src/services/organization/is-armee-domain.ts @@ -0,0 +1,15 @@ +// + +import { isDomainValid } from "@proconnect-gouv/proconnect.core/security"; + +// + +const ARMEE_DOMAINS = ["intradef.gouv.fr", "def.gouv.fr"]; + +export function isArmeeDomain(domain: string): boolean { + if (!isDomainValid(domain)) { + return false; + } + + return ARMEE_DOMAINS.includes(domain); +} diff --git a/packages/identite/src/services/organization/is-commune.test.ts b/packages/identite/src/services/organization/is-commune.test.ts new file mode 100644 index 000000000..a1802b391 --- /dev/null +++ b/packages/identite/src/services/organization/is-commune.test.ts @@ -0,0 +1,37 @@ +// + +import { isCommune } from "#src/services/organization"; +import type { Organization } from "#src/types"; +import { + communaute_de_communes_org_info, + dinum_org_info, + lamalou_org_info, +} from "#testing/seed/organizations"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +describe("isCommune", () => { + it("should return false for bad call", () => { + assert.equal(isCommune({} as Organization), false); + }); + + it("should return true for commune", () => { + assert.equal(isCommune(lamalou_org_info), true); + }); + + it("should return false for administration centrale", () => { + assert.equal(isCommune(dinum_org_info), false); + }); + + it("should return false for communaute de communes by default", () => { + assert.equal(isCommune(communaute_de_communes_org_info), false); + }); + + it("should return false for communaute de communes when considerCommunauteDeCommunesAsCommune is false", () => { + assert.equal(isCommune(communaute_de_communes_org_info, false), false); + }); + + it("should return true for communaute de communes when considerCommunauteDeCommunesAsCommune is true", () => { + assert.equal(isCommune(communaute_de_communes_org_info, true), true); + }); +}); diff --git a/packages/identite/src/services/organization/is-commune.ts b/packages/identite/src/services/organization/is-commune.ts new file mode 100644 index 000000000..4dbf4a8d5 --- /dev/null +++ b/packages/identite/src/services/organization/is-commune.ts @@ -0,0 +1,23 @@ +// + +import type { Organization } from "#src/types"; + +// + +const COMMUNE_CATEGORIES = [ + "Commune et commune nouvelle", + "Commune associée et commune déléguée", +]; + +export function isCommune( + { + cached_libelle_categorie_juridique, + }: Pick, + considerCommunauteDeCommunesAsCommune = false, +): boolean { + const categories = considerCommunauteDeCommunesAsCommune + ? [...COMMUNE_CATEGORIES, "Communauté de communes"] + : COMMUNE_CATEGORIES; + + return categories.includes(cached_libelle_categorie_juridique || ""); +} diff --git a/packages/identite/src/services/organization/is-education-nationale-domain.test.ts b/packages/identite/src/services/organization/is-education-nationale-domain.test.ts new file mode 100644 index 000000000..b714d3646 --- /dev/null +++ b/packages/identite/src/services/organization/is-education-nationale-domain.test.ts @@ -0,0 +1,31 @@ +// + +import { isEducationNationaleDomain } from "#src/services/organization"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +describe("isEducationNationaleDomain", () => { + it("should return false for non educ nat domain with z prefix", () => { + assert.equal(isEducationNationaleDomain("zac-orleans.fr"), false); + }); + + it("should return false for educ nat domain with .net suffix", () => { + assert.equal(isEducationNationaleDomain("ac-bordeaux.fr.net"), false); + }); + + it("should return false for educ nat domain with .gouv.fr suffix", () => { + assert.equal(isEducationNationaleDomain("ac-bordeaux.gouv.fr"), false); + }); + + it("should return true for educ nat domain with hyphen", () => { + assert.equal(isEducationNationaleDomain("ac-orleans-tours.fr"), true); + }); + + it("should return true for educ nat domain", () => { + assert.equal(isEducationNationaleDomain("ac-bordeaux.fr"), true); + }); + + it("should return false for invalid domain", () => { + assert.equal(isEducationNationaleDomain(""), false); + }); +}); diff --git a/packages/identite/src/services/organization/is-education-nationale-domain.ts b/packages/identite/src/services/organization/is-education-nationale-domain.ts new file mode 100644 index 000000000..aa97ec0ee --- /dev/null +++ b/packages/identite/src/services/organization/is-education-nationale-domain.ts @@ -0,0 +1,13 @@ +// + +import { isDomainValid } from "@proconnect-gouv/proconnect.core/security"; + +// + +export function isEducationNationaleDomain(domain: string): boolean { + if (!isDomainValid(domain)) { + return false; + } + + return domain.match(/^ac-[a-zA-Z0-9-]*\.fr$/) !== null; +} diff --git a/packages/identite/src/services/organization/is-etablissement-scolaire-du-premier-et-second-degre.test.ts b/packages/identite/src/services/organization/is-etablissement-scolaire-du-premier-et-second-degre.test.ts new file mode 100644 index 000000000..fe83c3418 --- /dev/null +++ b/packages/identite/src/services/organization/is-etablissement-scolaire-du-premier-et-second-degre.test.ts @@ -0,0 +1,140 @@ +// + +import { isEtablissementScolaireDuPremierEtSecondDegre } from "#src/services/organization"; +import type { Organization } from "#src/types"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +describe("isEtablissementScolaireDuPremierEtSecondDegre", () => { + it("should return false for unipersonnelle organization", () => { + const indep_org_info = { + cached_activite_principale: "85.31Z", + cached_libelle_activite_principale: + "85.31Z - Enseignement secondaire général", + cached_categorie_juridique: "1000", + cached_libelle_categorie_juridique: "Entrepreneur individuel", + } as Organization; + assert.equal( + isEtablissementScolaireDuPremierEtSecondDegre(indep_org_info), + false, + ); + }); + + it("should return true for lycee public", () => { + const lycee_public_org_info = { + cached_activite_principale: "85.31Z", + cached_libelle_activite_principale: + "85.31Z - Enseignement secondaire général", + cached_categorie_juridique: "7331", + cached_libelle_categorie_juridique: + "Établissement public local d'enseignement", + } as Organization; + assert.equal( + isEtablissementScolaireDuPremierEtSecondDegre(lycee_public_org_info), + true, + ); + }); + + it("should return true for college public", () => { + const college_public_org_info = { + cached_activite_principale: "85.31Z", + cached_libelle_activite_principale: + "85.31Z - Enseignement secondaire général", + cached_categorie_juridique: "7331", + cached_libelle_categorie_juridique: + "Établissement public local d'enseignement", + } as Organization; + assert.equal( + isEtablissementScolaireDuPremierEtSecondDegre(college_public_org_info), + true, + ); + }); + + it("should return true for lycee professionnel public", () => { + const lycee_pro_org_info = { + cached_activite_principale: "85.32Z", + cached_libelle_activite_principale: + "85.32Z - Enseignement secondaire technique ou professionnel", + cached_categorie_juridique: "7331", + cached_libelle_categorie_juridique: + "Établissement public local d'enseignement", + } as Organization; + assert.equal( + isEtablissementScolaireDuPremierEtSecondDegre(lycee_pro_org_info), + true, + ); + }); + + it("should return false for lycee prive", () => { + const lycee_prive_org_info = { + cached_activite_principale: "85.31Z", + cached_libelle_activite_principale: + "85.31Z - Enseignement secondaire général", + cached_categorie_juridique: "9220", + cached_libelle_categorie_juridique: "Association déclarée", + } as Organization; + assert.equal( + isEtablissementScolaireDuPremierEtSecondDegre(lycee_prive_org_info), + false, + ); + }); + + it("should return true for ecole primaire publique", () => { + const ecole_primaire_publique_org_info = { + cached_activite_principale: "85.20Z", + cached_libelle_activite_principale: "85.20Z - Enseignement primaire", + cached_categorie_juridique: "7210", + cached_libelle_categorie_juridique: "Commune et commune nouvelle", + } as Organization; + assert.equal( + isEtablissementScolaireDuPremierEtSecondDegre( + ecole_primaire_publique_org_info, + ), + true, + ); + }); + + it("should return false for ecole primaire privee", () => { + const ecole_primaire_privee_org_info = { + cached_activite_principale: "85.20Z", + cached_libelle_activite_principale: "85.20Z - Enseignement primaire", + cached_categorie_juridique: "9220", + cached_libelle_categorie_juridique: "Association déclarée", + } as Organization; + assert.equal( + isEtablissementScolaireDuPremierEtSecondDegre( + ecole_primaire_privee_org_info, + ), + false, + ); + }); + + it("should return true for ecole maternelle publique", () => { + const ecole_maternelle_publique_org_info = { + cached_activite_principale: "85.10Z", + cached_libelle_activite_principale: "85.10Z - Enseignement pré-primaire", + cached_categorie_juridique: "7210", + cached_libelle_categorie_juridique: "Commune et commune nouvelle", + } as Organization; + assert.equal( + isEtablissementScolaireDuPremierEtSecondDegre( + ecole_maternelle_publique_org_info, + ), + true, + ); + }); + + it("should return false for administration centrale", () => { + const admin_centrale_org_info = { + cached_activite_principale: "84.11Z", + cached_libelle_activite_principale: + "84.11Z - Administration publique générale", + cached_categorie_juridique: "7120", + cached_libelle_categorie_juridique: "Service central d'un ministère", + } as Organization; + assert.equal( + isEtablissementScolaireDuPremierEtSecondDegre(admin_centrale_org_info), + false, + ); + }); +}); diff --git a/packages/identite/src/services/organization/is-etablissement-scolaire-du-premier-et-second-degre.ts b/packages/identite/src/services/organization/is-etablissement-scolaire-du-premier-et-second-degre.ts new file mode 100644 index 000000000..ff2622561 --- /dev/null +++ b/packages/identite/src/services/organization/is-etablissement-scolaire-du-premier-et-second-degre.ts @@ -0,0 +1,36 @@ +// + +import type { Organization } from "#src/types"; + +// + +export function isEtablissementScolaireDuPremierEtSecondDegre({ + cached_libelle_activite_principale, + cached_libelle_categorie_juridique, +}: Pick< + Organization, + "cached_libelle_activite_principale" | "cached_libelle_categorie_juridique" +>): boolean { + const isCollegeOuLyceePublic = + (cached_libelle_activite_principale === + "85.31Z - Enseignement secondaire général" || + cached_libelle_activite_principale === + "85.32Z - Enseignement secondaire technique ou professionnel") && + cached_libelle_categorie_juridique === + "Établissement public local d'enseignement"; + + const isEcolePrimairePublique = + cached_libelle_activite_principale === "85.20Z - Enseignement primaire" && + cached_libelle_categorie_juridique === "Commune et commune nouvelle"; + + const isEcoleMaternellePublique = + cached_libelle_activite_principale === + "85.10Z - Enseignement pré-primaire" && + cached_libelle_categorie_juridique === "Commune et commune nouvelle"; + + return ( + isCollegeOuLyceePublic || + isEcolePrimairePublique || + isEcoleMaternellePublique + ); +} diff --git a/packages/identite/src/services/organization/is-small-association.test.ts b/packages/identite/src/services/organization/is-small-association.test.ts new file mode 100644 index 000000000..4f96a7e87 --- /dev/null +++ b/packages/identite/src/services/organization/is-small-association.test.ts @@ -0,0 +1,29 @@ +// + +import { isSmallAssociation } from "#src/services/organization"; +import type { Organization } from "#src/types"; +import { + association_org_info, + entreprise_unipersonnelle_org_info, + small_association_org_info, +} from "#testing/seed/organizations"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +describe("isSmallAssociation", () => { + it("should return false for bad call", () => { + assert.equal(isSmallAssociation({} as Organization), false); + }); + + it("should return false for unipersonnelle organization", () => { + assert.equal(isSmallAssociation(entreprise_unipersonnelle_org_info), false); + }); + + it("should return true for association", () => { + assert.equal(isSmallAssociation(association_org_info), true); + }); + + it("should return true for small association", () => { + assert.equal(isSmallAssociation(small_association_org_info), true); + }); +}); diff --git a/packages/identite/src/services/organization/is-small-association.ts b/packages/identite/src/services/organization/is-small-association.ts new file mode 100644 index 000000000..86ac359a7 --- /dev/null +++ b/packages/identite/src/services/organization/is-small-association.ts @@ -0,0 +1,23 @@ +// + +import type { Organization } from "#src/types"; + +export function isSmallAssociation({ + cached_libelle_categorie_juridique, + cached_tranche_effectifs, +}: Pick< + Organization, + "cached_libelle_categorie_juridique" | "cached_tranche_effectifs" +>): boolean { + // check that the organization has the right catégorie juridique + const cat_jur_ok = ["Association déclarée"].includes( + cached_libelle_categorie_juridique || "", + ); + + // check that the organization has the right tranche effectifs + const tra_eff_ok = [null, "NN", "00", "01", "02", "03", "11", "12"].includes( + cached_tranche_effectifs, + ); + + return cat_jur_ok && tra_eff_ok; +} diff --git a/packages/identite/src/services/organization/is-waste-management-organization.test.ts b/packages/identite/src/services/organization/is-waste-management-organization.test.ts new file mode 100644 index 000000000..4fb9a977e --- /dev/null +++ b/packages/identite/src/services/organization/is-waste-management-organization.test.ts @@ -0,0 +1,36 @@ +// + +import { isWasteManagementOrganization } from "#src/services/organization"; +import type { Organization } from "#src/types"; +import { + lamalou_org_info, + trackdechets_public_org_info, +} from "#testing/seed/organizations"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +describe("isWasteManagementOrganization", () => { + it("should return false for collectivité territoriale", () => { + assert.equal(isWasteManagementOrganization(lamalou_org_info), false); + }); + + it("should return true for waste management organization", () => { + assert.equal( + isWasteManagementOrganization(trackdechets_public_org_info), + true, + ); + }); + + it("should return false for organization with undefined activite principale", () => { + assert.equal(isWasteManagementOrganization({} as Organization), false); + }); + + it("should return false for organization with null activite principale", () => { + assert.equal( + isWasteManagementOrganization({ + cached_libelle_activite_principale: null, + } as unknown as Organization), + false, + ); + }); +}); diff --git a/packages/identite/src/services/organization/is-waste-management-organization.ts b/packages/identite/src/services/organization/is-waste-management-organization.ts new file mode 100644 index 000000000..bae372cb9 --- /dev/null +++ b/packages/identite/src/services/organization/is-waste-management-organization.ts @@ -0,0 +1,27 @@ +// + +import type { Organization } from "#src/types"; + +// + +const WASTE_MANAGEMENT_ACTIVITIES = [ + "38.11Z - Collecte des déchets non dangereux", + "38.12Z - Collecte des déchets dangereux", + "38.21Z - Traitement et élimination des déchets non dangereux", + "38.22Z - Traitement et élimination des déchets dangereux", + "38.31Z - Démantèlement d'épaves", + "38.32Z - Récupération de déchets triés", + "39.00Z - Dépollution et autres services de gestion des déchets", +]; + +export function isWasteManagementOrganization({ + cached_libelle_activite_principale, +}: Pick): boolean { + if (!cached_libelle_activite_principale) { + return false; + } + + return WASTE_MANAGEMENT_ACTIVITIES.includes( + cached_libelle_activite_principale, + ); +} diff --git a/packages/identite/testing/seed/organizations/index.ts b/packages/identite/testing/seed/organizations/index.ts index 5a64f935f..10ab54321 100644 --- a/packages/identite/testing/seed/organizations/index.ts +++ b/packages/identite/testing/seed/organizations/index.ts @@ -126,6 +126,18 @@ export const rogal_dorn_org_info = { cached_libelle_categorie_juridique: "Entrepreneur individuel", } as Organization; +export const communaute_de_communes_org_info = { + siret: "24560000100013", + cached_tranche_effectifs: "21", + cached_tranche_effectifs_unite_legale: "21", + cached_libelle_tranche_effectif: "50 à 99 salariés", + cached_activite_principale: "84.11Z", + cached_libelle_activite_principale: + "84.11Z - Administration publique générale", + cached_categorie_juridique: "7346", + cached_libelle_categorie_juridique: "Communauté de communes", +} as Organization; + export const papillon_org_info = { cached_activite_principale: "47.59A", cached_adresse: diff --git a/src/controllers/user/official-contact-email-verification.ts b/src/controllers/user/official-contact-email-verification.ts index 428d55ad9..9bb19cd61 100644 --- a/src/controllers/user/official-contact-email-verification.ts +++ b/src/controllers/user/official-contact-email-verification.ts @@ -1,3 +1,4 @@ +import { getOrganizationTypeLabel } from "@proconnect-gouv/proconnect.identite/services/organization"; import { to } from "await-to-js"; import type { NextFunction, Request, Response } from "express"; import { z } from "zod"; @@ -19,7 +20,6 @@ import { optionalBooleanSchema, } from "../../services/custom-zod-schemas"; import getNotificationsFromRequest from "../../services/get-notifications-from-request"; -import { getOrganizationTypeLabel } from "../../services/organization"; export const getOfficialContactEmailVerificationController = async ( req: Request, diff --git a/src/managers/organization/join.ts b/src/managers/organization/join.ts index bafd97812..3a99f21fd 100644 --- a/src/managers/organization/join.ts +++ b/src/managers/organization/join.ts @@ -6,15 +6,19 @@ import { InvalidSiretError, OrganizationNotFoundError, } from "@proconnect-gouv/proconnect.identite/errors"; -import { forceJoinOrganizationFactory } from "@proconnect-gouv/proconnect.identite/managers/organization"; import { - isDomainAllowedForOrganization, - isEntrepriseUnipersonnelle, - isPublicService, - isSyndicatCommunal, + forceJoinOrganizationFactory, + joinOrganization as joinOrganizationDecision, + type JoinContext, + type JoinErrorReason, +} from "@proconnect-gouv/proconnect.identite/managers/organization"; +import { + isArmeeDomain, + isCommune, + isEducationNationaleDomain, + isEtablissementScolaireDuPremierEtSecondDegre, } from "@proconnect-gouv/proconnect.identite/services/organization"; import { - LinkTypes, ModerationTypeSchema, type Organization, type User, @@ -24,6 +28,7 @@ import * as Sentry from "@sentry/node"; import { isEmpty, some } from "lodash-es"; import { AssertionError } from "node:assert"; import { inspect } from "node:util"; +import { match } from "ts-pattern"; import { CRISP_WEBSITE_ID, FEATURE_BYPASS_MODERATION, @@ -71,14 +76,6 @@ import { usesAFreeEmailProvider, } from "../../services/email"; import { logger } from "../../services/log"; -import { - hasLessThanFiftyEmployees, - isArmeeDomain, - isCommune, - isEducationNationaleDomain, - isEtablissementScolaireDuPremierEtSecondDegre, - isSmallAssociation, -} from "../../services/organization"; import { unableToAutoJoinOrganizationMd } from "../../views/mails/unable-to-auto-join-organization"; import { getOrganizationsByUserId, markDomainAsVerified } from "./main"; @@ -95,6 +92,7 @@ export const doSuggestOrganizations = async ({ }); return suggestedOrganizations.length > 0; }; + export const getOrganizationSuggestions = async ({ user_id, email, @@ -155,6 +153,58 @@ export const upsertOrganization = async (siret: string) => { return organization; }; +async function fetchContactEmail( + organization: Organization, +): Promise { + const isSchool = isEtablissementScolaireDuPremierEtSecondDegre(organization); + const isCommuneNotSchool = isCommune(organization) && !isSchool; + + if (isCommuneNotSchool) { + try { + return await getAnnuaireServicePublicContactEmail( + organization.cached_code_officiel_geographique, + organization.cached_code_postal, + ); + } catch (err) { + logger.error(inspect(err, { depth: 3 })); + Sentry.captureException(err); + return null; + } + } + + if (isSchool) { + try { + return await getAnnuaireEducationNationaleContactEmail( + organization.siret, + ); + } catch (err) { + logger.error(err); + Sentry.captureException(err); + return null; + } + } + + return null; +} + +function throwJoinError( + reason: JoinErrorReason, + organization_id: number, +): never { + switch (reason) { + case "domain_not_allowed": + throw new DomainNotAllowedForOrganizationError(organization_id); + case "domain_refused": + throw new DomainRefusedForOrganizationError(organization_id); + case "gouv_fr_domain_forbidden_for_private_org": + throw new GouvFrDomainsForbiddenForPrivateOrg(); + case "organization_not_active": + throw new OrganizationNotActiveError(); + case "public_service_requires_professional_email": + throw new AccessRestrictedToPublicServiceEmailError(); + } +} + export const joinOrganization = async ({ organization, user_id, @@ -166,15 +216,11 @@ export const joinOrganization = async ({ confirmed: boolean; certificationRequested?: boolean; }): Promise => { - const { siret } = organization; - - // Ensure the organization is active - if (!organization.cached_est_active) { - throw new OrganizationNotActiveError(); - } + const { id: organization_id } = organization; - // Ensure user_id is valid const user = await getUserById(user_id); + const { email } = user; + const domain = getEmailDomain(email); const usersOrganizations = await findByUserId(user_id); if (some(usersOrganizations, ["id", organization.id])) { @@ -213,85 +259,39 @@ export const joinOrganization = async ({ }); } - const { id: organization_id } = organization; - const { email } = user; - const domain = getEmailDomain(email); - const organizationEmailDomains = - await findEmailDomainsByOrganizationId(organization_id); - - if (!isDomainAllowedForOrganization(siret, domain)) { - throw new DomainNotAllowedForOrganizationError(organization_id); - } - - if ( - some(organizationEmailDomains, { domain, verification_type: "refused" }) - ) { - throw new DomainRefusedForOrganizationError(organization_id); - } - - if (domain.endsWith("gouv.fr") && !isPublicService(organization)) { - throw new GouvFrDomainsForbiddenForPrivateOrg(); - } - if (certificationRequested) { throw new PendingCertificationDirigeantError(organization_id); } - if (isEntrepriseUnipersonnelle(organization)) { - return await linkUserToOrganization({ - organization_id, - user_id, - verification_type: - LinkTypes.enum.no_verification_means_for_entreprise_unipersonnelle, - }); - } - - if (isSmallAssociation(organization)) { - return await linkUserToOrganization({ - organization_id, - user_id, - verification_type: - LinkTypes.enum.no_verification_means_for_small_association, - }); - } - - if ( - !isCommune(organization, true) && - isAFreeEmailProvider(email) && - isPublicService(organization) && - !isSyndicatCommunal(organization) - ) { - throw new AccessRestrictedToPublicServiceEmailError(); - } - - if ( - isAFreeEmailProvider(email) && - !hasLessThanFiftyEmployees(organization) && - !confirmed && - !isSyndicatCommunal(organization) - ) { - throw new UserMustConfirmToJoinOrganizationError(organization_id); - } - - if ( - isCommune(organization) && - !isEtablissementScolaireDuPremierEtSecondDegre(organization) - ) { - let contactEmail; - try { - contactEmail = await getAnnuaireServicePublicContactEmail( - organization.cached_code_officiel_geographique, - organization.cached_code_postal, - ); - } catch (err) { - logger.error(inspect(err, { depth: 3 })); - Sentry.captureException(err); - } - - if (isEmailValid(contactEmail)) { - const contactDomain = getEmailDomain(contactEmail); - - if (!isAFreeEmailProvider(contactDomain)) { + const organizationEmailDomains = + await findEmailDomainsByOrganizationId(organization_id); + const contactEmail = await fetchContactEmail(organization); + const contactDomain = contactEmail ? getEmailDomain(contactEmail) : null; + + const context: JoinContext = { + contactEmail, + domain, + featureBypassModeration: FEATURE_BYPASS_MODERATION, + isContactDomainFree: contactDomain + ? isAFreeEmailProvider(contactDomain) + : true, + isContactEmailSameDomain: contactDomain === domain, + isContactEmailValid: isEmailValid(contactEmail), + isFreeEmailProvider: isAFreeEmailProvider(domain), + organization, + organizationEmailDomains, + userEmail: email, + userHasConfirmed: confirmed, + }; + + const decision = joinOrganizationDecision(context); + + return match(decision) + .with({ type: "error" }, ({ reason }) => { + throwJoinError(reason, organization_id); + }) + .with({ type: "link" }, async (d) => { + if (d.should_mark_contact_domain_verified && contactDomain) { await markDomainAsVerified({ organization_id, domain: contactDomain, @@ -299,115 +299,36 @@ export const joinOrganization = async ({ }); } - if (contactEmail === email) { - return await linkUserToOrganization({ - organization_id, - user_id, - verification_type: LinkTypes.enum.official_contact_email, - }); - } - - if (!isAFreeEmailProvider(contactDomain) && contactDomain === domain) { - return await linkUserToOrganization({ - organization_id, - user_id, - verification_type: LinkTypes.enum.domain, - }); - } - - return await linkUserToOrganization({ + return linkUserToOrganization({ organization_id, user_id, - verification_type: LinkTypes.enum.code_sent_to_official_contact_email, - needs_official_contact_email_verification: true, + verification_type: + d.verification_type as UserOrganizationLink["verification_type"], + is_external: d.is_external, + needs_official_contact_email_verification: + d.needs_official_contact_email_verification, }); - } - } - - if (isEtablissementScolaireDuPremierEtSecondDegre(organization)) { - let contactEmail; - try { - contactEmail = await getAnnuaireEducationNationaleContactEmail(siret); - } catch (err) { - logger.error(err); - Sentry.captureException(err); - } - - if (contactEmail === email) { - return await linkUserToOrganization({ - organization_id, + }) + .with({ type: "needs_confirmation" }, () => { + throw new UserMustConfirmToJoinOrganizationError(organization_id); + }) + .with({ type: "moderation_required" }, async () => { + await createModeration({ user_id, - verification_type: LinkTypes.enum.official_contact_email, + organization_id, + type: ModerationTypeSchema.enum.non_verified_domain, + ticket_id: null, }); - } - - if (isEmailValid(contactEmail)) { - return await linkUserToOrganization({ + return linkUserToOrganization({ organization_id, user_id, - verification_type: LinkTypes.enum.code_sent_to_official_contact_email, - needs_official_contact_email_verification: true, + verification_type: "domain_not_verified_yet", }); - } - } - - if ( - some(organizationEmailDomains, { domain, verification_type: "verified" }) - ) { - return await linkUserToOrganization({ - organization_id, - user_id, - verification_type: LinkTypes.enum.domain, - }); - } - - if ( - some(organizationEmailDomains, { domain, verification_type: "external" }) - ) { - return await linkUserToOrganization({ - organization_id, - user_id, - is_external: true, - verification_type: LinkTypes.enum.domain, - }); - } - - if ( - some(organizationEmailDomains, { - domain, - verification_type: "trackdechets_postal_mail", }) - ) { - return await linkUserToOrganization({ - organization_id, - user_id, - verification_type: LinkTypes.enum.domain, - }); - } - - if (FEATURE_BYPASS_MODERATION) { - return await linkUserToOrganization({ - organization_id, - user_id, - verification_type: LinkTypes.enum.bypassed, - }); - } - - if (some(organizationEmailDomains, { domain, verification_type: null })) { - await createModeration({ - user_id, - organization_id, - type: ModerationTypeSchema.enum.non_verified_domain, - ticket_id: null, - }); - return await linkUserToOrganization({ - organization_id, - user_id, - verification_type: LinkTypes.enum.domain_not_verified_yet, - }); - } - - throw new UnableToAutoJoinOrganizationError(organization_id); + .with({ type: "unable_to_auto_join" }, () => { + throw new UnableToAutoJoinOrganizationError(organization_id); + }) + .exhaustive(); }; export const forceJoinOrganization = forceJoinOrganizationFactory({ @@ -433,7 +354,6 @@ export const greetForJoiningOrganization = async ({ const { given_name, family_name, email } = await getUserById(user_id); - // Welcome the user when he joins is first organization as he may now be able to connect await sendMail({ to: [email], subject: "Votre compte ProConnect a bien été créé", diff --git a/src/managers/organization/official-contact-email-verification.ts b/src/managers/organization/official-contact-email-verification.ts index 761bd83c8..e7b4963ad 100644 --- a/src/managers/organization/official-contact-email-verification.ts +++ b/src/managers/organization/official-contact-email-verification.ts @@ -1,6 +1,10 @@ import { generateDicewarePassword } from "@proconnect-gouv/proconnect.core/security"; import { OfficialContactEmailVerification } from "@proconnect-gouv/proconnect.email"; import { NotFoundError } from "@proconnect-gouv/proconnect.identite/errors"; +import { + isCommune, + isEtablissementScolaireDuPremierEtSecondDegre, +} from "@proconnect-gouv/proconnect.identite/services/organization"; import type { UserOrganizationLink } from "@proconnect-gouv/proconnect.identite/types"; import { isEmpty } from "lodash-es"; import { HOST } from "../../config/env"; @@ -18,10 +22,6 @@ import { } from "../../repositories/organization/getters"; import { updateUserOrganizationLink } from "../../repositories/organization/setters"; import { isExpired } from "../../services/is-expired"; -import { - isCommune, - isEtablissementScolaireDuPremierEtSecondDegre, -} from "../../services/organization"; const OFFICIAL_CONTACT_EMAIL_VERIFICATION_TOKEN_EXPIRATION_DURATION_IN_MINUTES = 60; diff --git a/src/services/oidc-account-adapter.ts b/src/services/oidc-account-adapter.ts index 705301944..109a9faae 100644 --- a/src/services/oidc-account-adapter.ts +++ b/src/services/oidc-account-adapter.ts @@ -1,6 +1,9 @@ // -import { isPublicService } from "@proconnect-gouv/proconnect.identite/services/organization"; +import { + isCommune, + isPublicService, +} from "@proconnect-gouv/proconnect.identite/services/organization"; import { UserClaimsSchema, type UserClaims, @@ -14,7 +17,6 @@ import { getSelectedOrganizationId } from "../repositories/redis/selected-organi import { findById as findUserById } from "../repositories/user"; import { logger } from "./log"; import { mustReturnOneOrganizationInPayload } from "./must-return-one-organization-in-payload"; -import { isCommune } from "./organization"; export const findAccount: FindAccount = async (_ctx, sub) => { const user = await findUserById(parseInt(sub, 10)); diff --git a/src/services/organization.ts b/src/services/organization.ts deleted file mode 100644 index 02338d33b..000000000 --- a/src/services/organization.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { isDomainValid } from "@proconnect-gouv/proconnect.core/security"; -import { - isEntrepriseUnipersonnelle, - isPublicService, -} from "@proconnect-gouv/proconnect.identite/services/organization"; -import type { Organization } from "@proconnect-gouv/proconnect.identite/types"; - -export const isSmallAssociation = ({ - cached_libelle_categorie_juridique, - cached_tranche_effectifs, -}: Organization): boolean => { - // check that the organization has the right catégorie juridique - const cat_jur_ok = ["Association déclarée"].includes( - cached_libelle_categorie_juridique || "", - ); - - // check that the organization has the right tranche effectifs - const tra_eff_ok = [null, "NN", "00", "01", "02", "03", "11", "12"].includes( - cached_tranche_effectifs, - ); - - return cat_jur_ok && tra_eff_ok; -}; - -export const isCommune = ( - { cached_libelle_categorie_juridique }: Organization, - considerCommunauteDeCommunesAsCommune = false, -): boolean => { - let cat_jur = [ - "Commune et commune nouvelle", - "Commune associée et commune déléguée", - ]; - - if (considerCommunauteDeCommunesAsCommune) { - cat_jur.push("Communauté de communes"); - } - - return cat_jur.includes(cached_libelle_categorie_juridique || ""); -}; - -export const hasLessThanFiftyEmployees = ({ - cached_tranche_effectifs, -}: Organization): boolean => { - return [null, "NN", "00", "01", "02", "03", "11", "12"].includes( - cached_tranche_effectifs, - ); -}; - -export const isWasteManagementOrganization = ({ - cached_libelle_activite_principale, -}: Organization): boolean => { - if (!cached_libelle_activite_principale) { - return false; - } - - return [ - "38.11Z - Collecte des déchets non dangereux", - "38.12Z - Collecte des déchets dangereux", - "38.21Z - Traitement et élimination des déchets non dangereux", - "38.22Z - Traitement et élimination des déchets dangereux", - "38.31Z - Démantèlement d’épaves", - "38.32Z - Récupération de déchets triés", - "39.00Z - Dépollution et autres services de gestion des déchets", - ].includes(cached_libelle_activite_principale); -}; - -export const isEtablissementScolaireDuPremierEtSecondDegre = ({ - cached_libelle_activite_principale, - cached_libelle_categorie_juridique, -}: Organization) => { - const isCollegeOuLyceePublic = - (cached_libelle_activite_principale === - "85.31Z - Enseignement secondaire général" || - cached_libelle_activite_principale === - "85.32Z - Enseignement secondaire technique ou professionnel") && - cached_libelle_categorie_juridique === - "Établissement public local d'enseignement"; - - // Temporarily disabled because contact data from annuaire education nationale - // are not accurate enough. - // const isCollegeOuLyceePrive = - // (cached_libelle_activite_principale === - // "85.31Z - Enseignement secondaire général" || - // cached_libelle_activite_principale === - // "85.32Z - Enseignement secondaire technique ou professionnel") && - // cached_libelle_categorie_juridique === "Association déclarée"; - - const isEcolePrimairePublique = - cached_libelle_activite_principale === "85.20Z - Enseignement primaire" && - cached_libelle_categorie_juridique === "Commune et commune nouvelle"; - - // Temporarily disabled because contact data from annuaire education nationale - // are not accurate enough. - // const isEcolePrimairePrivee = - // cached_libelle_activite_principale === "85.20Z - Enseignement primaire" && - // cached_libelle_categorie_juridique === "Association déclarée"; - - const isEcoleMaternellePublique = - cached_libelle_activite_principale === - "85.10Z - Enseignement pré-primaire" && - cached_libelle_categorie_juridique === "Commune et commune nouvelle"; - - return ( - isCollegeOuLyceePublic || - isEcolePrimairePublique || - isEcoleMaternellePublique - ); -}; - -export const isEducationNationaleDomain = (domain: string) => { - if (!isDomainValid(domain)) { - return false; - } - - return domain.match(/^ac-[a-zA-Z0-9-]*\.fr$/) !== null; -}; - -export const isArmeeDomain = (domain: string) => { - if (!isDomainValid(domain)) { - return false; - } - const ARMEE_DOMAINS = ["intradef.gouv.fr", "def.gouv.fr"]; - return ARMEE_DOMAINS.includes(domain); -}; - -export const getOrganizationTypeLabel = (organization: Organization) => { - if (isEtablissementScolaireDuPremierEtSecondDegre(organization)) { - return "établissement scolaire"; - } else { - if (isCommune(organization)) { - return "mairie"; - } - - if (isPublicService(organization)) { - return "service"; - } - } - - if ( - isEntrepriseUnipersonnelle(organization) && - isWasteManagementOrganization(organization) - ) { - return "entreprise"; - } - - return "organisation"; -}; diff --git a/test/organization.test.ts b/test/organization.test.ts deleted file mode 100644 index 9c1d46303..000000000 --- a/test/organization.test.ts +++ /dev/null @@ -1,328 +0,0 @@ -import type { Organization } from "@proconnect-gouv/proconnect.identite/types"; -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import { - isCommune, - isEducationNationaleDomain, - isEtablissementScolaireDuPremierEtSecondDegre, - isSmallAssociation, - isWasteManagementOrganization, -} from "../src/services/organization"; - -const association_org_info = { - siret: "83511518900010", - cached_tranche_effectifs: "00", - cached_tranche_effectifs_unite_legale: "00", - cached_libelle_tranche_effectif: - "0 salarié (n'ayant pas d'effectif au 31/12 mais ayant employé des salariés au cours de l'année de référence), en 2020", - cached_activite_principale: "81.21Z", - cached_libelle_activite_principale: - "81.21Z - Nettoyage courant des bâtiments", - cached_categorie_juridique: "9220", - cached_libelle_categorie_juridique: "Association déclarée", -} as Organization; - -const small_association_org_info = { - siret: "39399933900046", - cached_tranche_effectifs: "12", - cached_tranche_effectifs_unite_legale: "12", - cached_libelle_tranche_effectif: "20 à 49 salariés, en 2022", - cached_activite_principale: "84.13Z", - cached_libelle_activite_principale: - "84.13Z - Administration publique (tutelle) des activités économiques", - cached_categorie_juridique: "9220", - cached_libelle_categorie_juridique: "Association déclarée", -} as Organization; - -const entreprise_unipersonnelle_org_info = { - siret: "82869625200018", - cached_tranche_effectifs: null, - cached_tranche_effectifs_unite_legale: null, - cached_libelle_tranche_effectif: null, - cached_activite_principale: "62.01Z", - cached_libelle_activite_principale: "62.01Z - Programmation informatique", - cached_categorie_juridique: "1000", - cached_libelle_categorie_juridique: "Entrepreneur individuel", -} as Organization; - -describe("isSmallAssociation", () => { - it("should return false for bad call", () => { - assert.equal(isSmallAssociation({} as Organization), false); - }); - - it("should return false for unipersonnelle organization", () => { - assert.equal(isSmallAssociation(entreprise_unipersonnelle_org_info), false); - }); - - it("should return true for association", () => { - assert.equal(isSmallAssociation(association_org_info), true); - }); - - it("should return true for small association", () => { - assert.equal(isSmallAssociation(small_association_org_info), true); - }); -}); - -const lamalou_org_info = { - siret: "21340126800130", - cached_tranche_effectifs: "12", - cached_tranche_effectifs_unite_legale: "21", - cached_libelle_tranche_effectif: "20 à 49 salariés, en 2020", - cached_activite_principale: "84.11Z", - cached_libelle_activite_principale: - "84.11Z - Administration publique générale", - cached_categorie_juridique: "7210", - cached_libelle_categorie_juridique: "Commune et commune nouvelle", -} as Organization; - -const dinum_org_info = { - siret: "13002526500013", - cached_tranche_effectifs: "22", - cached_tranche_effectifs_unite_legale: "22", - cached_libelle_tranche_effectif: "100 à 199 salariés, en 2020", - cached_activite_principale: "84.11Z", - cached_libelle_activite_principale: - "84.11Z - Administration publique générale", - cached_categorie_juridique: "7120", - cached_libelle_categorie_juridique: "Service central d'un ministère", -} as Organization; - -const communaute_de_communes_org_info = { - siret: "20007184300060", - cached_tranche_effectifs: "22", - cached_tranche_effectifs_unite_legale: "22", - cached_libelle_tranche_effectif: "100 à 199 salariés, en 2022", - cached_activite_principale: "84.11Z", - cached_libelle_activite_principale: - "84.11Z - Administration publique générale", - cached_categorie_juridique: "7346", - cached_libelle_categorie_juridique: "Communauté de communes", -} as Organization; - -describe("isCommune", () => { - it("should return false for bad call", () => { - assert.equal(isCommune({} as Organization), false); - }); - - it("should return true for collectivite territoriale", () => { - assert.equal(isCommune(lamalou_org_info), true); - }); - - it("should return false for administration centrale", () => { - assert.equal(isCommune(dinum_org_info), false); - }); - - it("should return false for communaute de communes by default", () => { - assert.equal(isCommune(communaute_de_communes_org_info), false); - }); - - it("should return false for communaute de communes when considerCommunauteDeCommunesAsCommune is false", () => { - assert.equal(isCommune(communaute_de_communes_org_info, false), false); - }); - - it("should return true for communaute de communes when considerCommunauteDeCommunesAsCommune is true", () => { - assert.equal(isCommune(communaute_de_communes_org_info, true), true); - }); -}); - -const trackdechets_public_org_info = { - siret: "25680169700010", - cached_tranche_effectifs: "NN", - cached_tranche_effectifs_unite_legale: "NN", - cached_libelle_tranche_effectif: - "Unité non employeuse (pas de salarié au cours de l'année de référence et pas d'effectif au 31/12)", - cached_activite_principale: "38.21Z", - cached_libelle_activite_principale: - "38.21Z - Traitement et élimination des déchets non dangereux", - cached_categorie_juridique: "7354", - cached_libelle_categorie_juridique: "Syndicat mixte fermé", -} as Organization; - -describe("isWasteManagementOrganization", () => { - it("should return false for collectivité territoriale", () => { - assert.equal(isWasteManagementOrganization(lamalou_org_info), false); - }); - - it("should return true for waste management organization", () => { - assert.equal( - isWasteManagementOrganization(trackdechets_public_org_info), - true, - ); - }); -}); - -describe("isEtablissementScolaireDuPremierEtSecondDegre", () => { - it("should return false for unipersonnelle organization", () => { - const indep_org_info = { - siret: "90243432300017", - cached_tranche_effectifs: null, - cached_tranche_effectifs_unite_legale: null, - cached_libelle_tranche_effectif: null, - cached_activite_principale: "85.31Z", - cached_libelle_activite_principale: - "85.31Z - Enseignement secondaire général", - cached_categorie_juridique: "1000 ", - cached_libelle_categorie_juridique: "Entrepreneur individuel", - } as Organization; - assert.equal( - isEtablissementScolaireDuPremierEtSecondDegre(indep_org_info), - false, - ); - }); - it("should return true for lycee public", () => { - const lycee_public_org_info = { - siret: "19500016100016", - cached_libelle: "Lycee general et technologique jean francois millet", - cached_nom_complet: "Lycee general et technologique jean francois millet", - cached_enseigne: "", - cached_tranche_effectifs: "22", - cached_tranche_effectifs_unite_legale: "22", - cached_libelle_tranche_effectif: "100 à 199 salariés, en 2020", - cached_etat_administratif: "A", - cached_est_active: "true", - cached_statut_diffusion: "O", - cached_est_diffusible: "true", - cached_adresse: "1 rue bougainville, 50130 Cherbourg-en-cotentin", - cached_code_postal: "50130", - cached_code_officiel_geographique: "50129", - cached_activite_principale: "85.31Z", - cached_libelle_activite_principale: - "85.31Z - Enseignement secondaire général", - cached_categorie_juridique: "7331", - cached_libelle_categorie_juridique: - "Établissement public local d'enseignement", - } as Organization; - assert.equal( - isEtablissementScolaireDuPremierEtSecondDegre(lycee_public_org_info), - true, - ); - }); - it("should return true for college public", () => { - const college_public_org_info = { - siret: "19120032800018", - cached_libelle: "College albert camus", - cached_nom_complet: "College albert camus", - cached_enseigne: "", - cached_tranche_effectifs: "21", - cached_tranche_effectifs_unite_legale: "21", - cached_libelle_tranche_effectif: "50 à 99 salariés, en 2020", - cached_etat_administratif: "A", - cached_est_active: "true", - cached_statut_diffusion: "O", - cached_est_diffusible: "true", - cached_adresse: "114 rue de la vallee du viaur, 12160 Baraqueville", - cached_code_postal: "12160", - cached_code_officiel_geographique: "12056", - cached_activite_principale: "85.31Z", - cached_libelle_activite_principale: - "85.31Z - Enseignement secondaire général", - cached_categorie_juridique: "7331", - cached_libelle_categorie_juridique: - "Établissement public local d'enseignement", - } as Organization; - assert.equal( - isEtablissementScolaireDuPremierEtSecondDegre(college_public_org_info), - true, - ); - }); - it("should return false for lycee prive", () => { - const lycee_prive_org_info = { - siret: "31458546400014", - cached_libelle: "Ogec maitrise de massabielle", - cached_nom_complet: "Ogec maitrise de massabielle", - cached_enseigne: "", - cached_tranche_effectifs: "21", - cached_tranche_effectifs_unite_legale: "22", - cached_libelle_tranche_effectif: "50 à 99 salariés, en 2020", - cached_etat_administratif: "A", - cached_est_active: "true", - cached_statut_diffusion: "O", - cached_est_diffusible: "true", - cached_adresse: "29 faubourg victor hugo, 97110 Pointe-à-pitre", - cached_code_postal: "97110", - cached_code_officiel_geographique: "97120", - cached_activite_principale: "85.31Z", - cached_libelle_activite_principale: - "85.31Z - Enseignement secondaire général", - cached_categorie_juridique: "9220", - cached_libelle_categorie_juridique: "Association déclarée", - } as Organization; - assert.equal( - isEtablissementScolaireDuPremierEtSecondDegre(lycee_prive_org_info), - false, - ); - }); - it("should return true for ecole primaire publique", () => { - const ecole_primaire_publique_org_info: Organization = { - siret: "21590009300273", - cached_libelle: - "Commune de villeneuve d ascq - Ecole primaire publique calmette", - cached_nom_complet: "Commune de villeneuve d ascq", - cached_enseigne: "Ecole primaire publique calmette", - cached_tranche_effectifs: "11", - cached_tranche_effectifs_unite_legale: "42", - cached_libelle_tranche_effectif: "10 à 19 salariés, en 2020", - cached_etat_administratif: "A", - cached_est_active: "true", - cached_statut_diffusion: "O", - cached_est_diffusible: "true", - cached_adresse: "48 rue de la contrescarpe, 59650 Villeneuve d'ascq", - cached_code_postal: "59650", - cached_code_officiel_geographique: "59009", - cached_activite_principale: "85.20Z", - cached_libelle_activite_principale: "85.20Z - Enseignement primaire", - cached_categorie_juridique: "7210", - cached_libelle_categorie_juridique: "Commune et commune nouvelle", - } as Organization; - assert.equal( - isEtablissementScolaireDuPremierEtSecondDegre( - ecole_primaire_publique_org_info, - ), - true, - ); - }); - it("should return false for ecole primaire privee", () => { - const ecole_primaire_privee_org_info = { - siret: "39945558300027", - cached_libelle: - "Groupe scolaire ste genevieve st joseph (OGEC) - Ecoles primaires ste genevieve st joseph", - cached_nom_complet: "Groupe scolaire ste genevieve st joseph (OGEC)", - cached_enseigne: "Ecoles primaires ste genevieve st joseph", - cached_tranche_effectifs: "11", - cached_tranche_effectifs_unite_legale: "22", - cached_libelle_tranche_effectif: "10 à 19 salariés, en 2020", - cached_etat_administratif: "A", - cached_est_active: "true", - cached_statut_diffusion: "O", - cached_est_diffusible: "true", - cached_adresse: "1 rue sarrus, 12000 Rodez", - cached_code_postal: "12000", - cached_code_officiel_geographique: "12202", - cached_activite_principale: "85.20Z", - cached_libelle_activite_principale: "85.20Z - Enseignement primaire", - cached_categorie_juridique: "9220", - cached_libelle_categorie_juridique: "Association déclarée", - } as Organization; - assert.equal( - isEtablissementScolaireDuPremierEtSecondDegre( - ecole_primaire_privee_org_info, - ), - false, - ); - }); -}); - -describe("isEducationNationaleDomain", () => { - ["zac-orleans.fr", "ac-bordeaux.fr.net", "ac-bordeaux.gouv.fr"].forEach( - (domain) => { - it("should return false for non educ nat domain", () => { - assert.equal(isEducationNationaleDomain(domain), false); - }); - }, - ); - ["ac-orleans-tours.fr", "ac-bordeaux.fr"].forEach((domain) => { - it("should return true for educ nat domain", () => { - assert.equal(isEducationNationaleDomain(domain), true); - }); - }); -});