From a28284b325c116d6382760d48f86effd7e13c312 Mon Sep 17 00:00:00 2001 From: Douglas DUTEIL Date: Mon, 17 Feb 2025 18:47:46 +0100 Subject: [PATCH] refactor(identite): extract markDomainAsVerified function --- .changeset/weak-insects-cry.md | 13 ++ .changeset/wicked-trainers-perform.md | 5 + .../services/email/get-email-domain.test.ts | 29 +++++ .../src/services/email/get-email-domain.ts | 20 +++ packages/core/src/services/email/index.ts | 3 +- ...omain.test.ts => is-a-free-domain.test.ts} | 2 +- .../{isAFreeDomain.ts => is-a-free-domain.ts} | 0 packages/core/types/tld-extract.d.ts | 9 +- packages/identite/package.json | 3 +- .../organization/__mocks__/diffusible.json | 0 .../__mocks__/partially-non-diffusible.json | 0 .../__mocks__/search-by-siren.json | 0 .../get-organization-info.test.ts | 0 .../organization/get-organization-info.ts | 2 +- .../src/{ => managers}/organization/index.ts | 2 +- .../mark-domain-as-verified.test.ts | 115 ++++++++++++++++++ .../organization/mark-domain-as-verified.ts | 84 +++++++++++++ .../email-domain/add-domain.test.ts | 35 ++++++ .../repositories/email-domain/add-domain.ts | 48 ++++++++ ...d-email-domains-by-organization-id.test.ts | 55 +++++++++ .../find-email-domains-by-organization-id.ts | 28 +++++ .../src/repositories/email-domain/index.ts | 2 + .../organization/find-by-id.test.ts | 51 ++++++++ .../repositories/organization/find-by-id.ts | 22 ++++ .../get-users-by-organization.test.ts | 85 +++++++++++++ .../organization/get-users-by-organization.ts | 45 +++++++ .../src/repositories/organization/index.ts | 5 + .../{ => repositories}/organization/upsert.ts | 0 .../organization/upset.test.ts | 30 ++--- .../src/repositories/user/create.test.ts | 20 +++ .../src/{ => repositories}/user/create.ts | 0 .../repositories/user/find-by-email.test.ts | 36 ++++++ .../{ => repositories}/user/find-by-email.ts | 0 .../src/repositories/user/find-by-id.test.ts | 36 ++++++ .../src/repositories/user/find-by-id.ts | 21 ++++ .../src/{ => repositories}/user/index.ts | 2 + .../update-user-organization-link.test.ts | 46 +++++++ .../user/update-user-organization-link.ts | 42 +++++++ .../src/repositories/user/update.test.ts | 26 ++++ .../src/{ => repositories}/user/update.ts | 0 .../identite/src/types/email-domain.ts | 2 +- packages/identite/src/types/index.ts | 2 + .../src/types/user-organization-link.ts | 4 +- packages/identite/src/user/create.test.ts | 31 ----- .../identite/src/user/find-by-email.test.ts | 45 ------- packages/identite/src/user/update.test.ts | 36 ------ packages/identite/testing/index.ts | 31 +++++ packages/identite/tsconfig.json | 4 +- packages/identite/tsconfig.lib.json | 2 +- src/connectors/api-sirene.ts | 2 +- src/connectors/debounce.ts | 2 +- src/controllers/organization.ts | 2 +- src/managers/organization/join.ts | 4 +- src/managers/organization/main.ts | 59 ++------- .../official-contact-email-verification.ts | 1 + src/repositories/email-domain.ts | 60 ++------- src/repositories/organization/getters.ts | 38 +----- src/repositories/organization/setters.ts | 35 +----- src/repositories/user.ts | 18 +-- src/services/email.ts | 14 +-- src/types/tld-extract.d.ts | 1 - test/email.test.ts | 25 +--- 62 files changed, 985 insertions(+), 355 deletions(-) create mode 100644 .changeset/weak-insects-cry.md create mode 100644 .changeset/wicked-trainers-perform.md create mode 100644 packages/core/src/services/email/get-email-domain.test.ts create mode 100644 packages/core/src/services/email/get-email-domain.ts rename packages/core/src/services/email/{isAFreeDomain.test.ts => is-a-free-domain.test.ts} (90%) rename packages/core/src/services/email/{isAFreeDomain.ts => is-a-free-domain.ts} (100%) rename packages/identite/src/{ => managers}/organization/__mocks__/diffusible.json (100%) rename packages/identite/src/{ => managers}/organization/__mocks__/partially-non-diffusible.json (100%) rename packages/identite/src/{ => managers}/organization/__mocks__/search-by-siren.json (100%) rename packages/identite/src/{ => managers}/organization/get-organization-info.test.ts (100%) rename packages/identite/src/{ => managers}/organization/get-organization-info.ts (98%) rename packages/identite/src/{ => managers}/organization/index.ts (51%) create mode 100644 packages/identite/src/managers/organization/mark-domain-as-verified.test.ts create mode 100644 packages/identite/src/managers/organization/mark-domain-as-verified.ts create mode 100644 packages/identite/src/repositories/email-domain/add-domain.test.ts create mode 100644 packages/identite/src/repositories/email-domain/add-domain.ts create mode 100644 packages/identite/src/repositories/email-domain/find-email-domains-by-organization-id.test.ts create mode 100644 packages/identite/src/repositories/email-domain/find-email-domains-by-organization-id.ts create mode 100644 packages/identite/src/repositories/email-domain/index.ts create mode 100644 packages/identite/src/repositories/organization/find-by-id.test.ts create mode 100644 packages/identite/src/repositories/organization/find-by-id.ts create mode 100644 packages/identite/src/repositories/organization/get-users-by-organization.test.ts create mode 100644 packages/identite/src/repositories/organization/get-users-by-organization.ts create mode 100644 packages/identite/src/repositories/organization/index.ts rename packages/identite/src/{ => repositories}/organization/upsert.ts (100%) rename packages/identite/src/{ => repositories}/organization/upset.test.ts (63%) create mode 100644 packages/identite/src/repositories/user/create.test.ts rename packages/identite/src/{ => repositories}/user/create.ts (100%) create mode 100644 packages/identite/src/repositories/user/find-by-email.test.ts rename packages/identite/src/{ => repositories}/user/find-by-email.ts (100%) create mode 100644 packages/identite/src/repositories/user/find-by-id.test.ts create mode 100644 packages/identite/src/repositories/user/find-by-id.ts rename packages/identite/src/{ => repositories}/user/index.ts (53%) create mode 100644 packages/identite/src/repositories/user/update-user-organization-link.test.ts create mode 100644 packages/identite/src/repositories/user/update-user-organization-link.ts create mode 100644 packages/identite/src/repositories/user/update.test.ts rename packages/identite/src/{ => repositories}/user/update.ts (100%) rename src/types/email-domain.d.ts => packages/identite/src/types/email-domain.ts (92%) rename src/types/user-organization-link.d.ts => packages/identite/src/types/user-organization-link.ts (88%) delete mode 100644 packages/identite/src/user/create.test.ts delete mode 100644 packages/identite/src/user/find-by-email.test.ts delete mode 100644 packages/identite/src/user/update.test.ts create mode 100644 packages/identite/testing/index.ts delete mode 100644 src/types/tld-extract.d.ts diff --git a/.changeset/weak-insects-cry.md b/.changeset/weak-insects-cry.md new file mode 100644 index 000000000..7aa8445e3 --- /dev/null +++ b/.changeset/weak-insects-cry.md @@ -0,0 +1,13 @@ +--- +"@gouvfr-lasuite/proconnect.core": minor +--- + +♻️ Prélevement de la fonction getEmailDomain + +Permet l'extraction du domain d'un email. + +```ts +import { getEmailDomain } from "@gouvfr-lasuite/proconnect.core/services/email"; + +getEmailDomain("lion.eljonson@darkangels.world"); // darkangels.world +``` diff --git a/.changeset/wicked-trainers-perform.md b/.changeset/wicked-trainers-perform.md new file mode 100644 index 000000000..7dddbaa52 --- /dev/null +++ b/.changeset/wicked-trainers-perform.md @@ -0,0 +1,5 @@ +--- +"@gouvfr-lasuite/proconnect.identite": minor +--- + +♻️ Prélevement d'un partie du buisness proconnect identité diff --git a/packages/core/src/services/email/get-email-domain.test.ts b/packages/core/src/services/email/get-email-domain.test.ts new file mode 100644 index 000000000..bbafc2102 --- /dev/null +++ b/packages/core/src/services/email/get-email-domain.test.ts @@ -0,0 +1,29 @@ +// + +import { assert } from "chai"; +import { getEmailDomain } from "./get-email-domain.js"; + +// + +describe(getEmailDomain.name, () => { + const data = [ + { + email: "user@beta.gouv.fr", + domain: "beta.gouv.fr", + }, + { + email: "user@notaires.fr", + domain: "notaires.fr", + }, + { + email: "user@subdomain.domain.org", + domain: "subdomain.domain.org", + }, + ]; + + data.forEach(({ email, domain }) => { + it("should return email domain", () => { + assert.equal(getEmailDomain(email), domain); + }); + }); +}); diff --git a/packages/core/src/services/email/get-email-domain.ts b/packages/core/src/services/email/get-email-domain.ts new file mode 100644 index 000000000..08d31f824 --- /dev/null +++ b/packages/core/src/services/email/get-email-domain.ts @@ -0,0 +1,20 @@ +// + +import { parse_host } from "tld-extract"; + +// + +/** + * Get the domain of an email address + * @example + * getEmailDomain("lion.eljonson@darkangels.world") // darkangels.world + * @param email - the email address + * @returns the domain of the email address + */ +export function getEmailDomain(email: string) { + const parts = email.split("@"); + const host = parts[parts.length - 1]; + const { sub, domain } = parse_host(host, { allowDotlessTLD: true }); + + return [sub, domain].filter(Boolean).join("."); +} diff --git a/packages/core/src/services/email/index.ts b/packages/core/src/services/email/index.ts index da7061c2d..d9d01866d 100644 --- a/packages/core/src/services/email/index.ts +++ b/packages/core/src/services/email/index.ts @@ -1,3 +1,4 @@ // -export * from "./isAFreeDomain.js"; +export * from "./get-email-domain.js"; +export * from "./is-a-free-domain.js"; diff --git a/packages/core/src/services/email/isAFreeDomain.test.ts b/packages/core/src/services/email/is-a-free-domain.test.ts similarity index 90% rename from packages/core/src/services/email/isAFreeDomain.test.ts rename to packages/core/src/services/email/is-a-free-domain.test.ts index 3d56ac82e..04cca8d0f 100644 --- a/packages/core/src/services/email/isAFreeDomain.test.ts +++ b/packages/core/src/services/email/is-a-free-domain.test.ts @@ -2,7 +2,7 @@ import { expect } from "chai"; import { test } from "mocha"; -import { isAFreeDomain } from "./isAFreeDomain.js"; +import { isAFreeDomain } from "./is-a-free-domain.js"; // diff --git a/packages/core/src/services/email/isAFreeDomain.ts b/packages/core/src/services/email/is-a-free-domain.ts similarity index 100% rename from packages/core/src/services/email/isAFreeDomain.ts rename to packages/core/src/services/email/is-a-free-domain.ts diff --git a/packages/core/types/tld-extract.d.ts b/packages/core/types/tld-extract.d.ts index 0211b4577..76cae902b 100644 --- a/packages/core/types/tld-extract.d.ts +++ b/packages/core/types/tld-extract.d.ts @@ -1,5 +1,12 @@ // declare module "tld-extract" { - function parse_host(domain: string, { allowDotlessTLD: boolean }): boolean; + function parse_host( + domain: string, + { allowDotlessTLD: boolean }, + ): { + tld: string; + domain: string; + sub: string; + }; } diff --git a/packages/identite/package.json b/packages/identite/package.json index 5973aedf7..2c206b634 100644 --- a/packages/identite/package.json +++ b/packages/identite/package.json @@ -15,7 +15,8 @@ "#src/*": { "types": "./dist/*/index.d.ts", "default": "./dist/*/index.js" - } + }, + "#testing": "./testing/index.ts" }, "exports": { "./*": { diff --git a/packages/identite/src/organization/__mocks__/diffusible.json b/packages/identite/src/managers/organization/__mocks__/diffusible.json similarity index 100% rename from packages/identite/src/organization/__mocks__/diffusible.json rename to packages/identite/src/managers/organization/__mocks__/diffusible.json diff --git a/packages/identite/src/organization/__mocks__/partially-non-diffusible.json b/packages/identite/src/managers/organization/__mocks__/partially-non-diffusible.json similarity index 100% rename from packages/identite/src/organization/__mocks__/partially-non-diffusible.json rename to packages/identite/src/managers/organization/__mocks__/partially-non-diffusible.json diff --git a/packages/identite/src/organization/__mocks__/search-by-siren.json b/packages/identite/src/managers/organization/__mocks__/search-by-siren.json similarity index 100% rename from packages/identite/src/organization/__mocks__/search-by-siren.json rename to packages/identite/src/managers/organization/__mocks__/search-by-siren.json diff --git a/packages/identite/src/organization/get-organization-info.test.ts b/packages/identite/src/managers/organization/get-organization-info.test.ts similarity index 100% rename from packages/identite/src/organization/get-organization-info.test.ts rename to packages/identite/src/managers/organization/get-organization-info.test.ts diff --git a/packages/identite/src/organization/get-organization-info.ts b/packages/identite/src/managers/organization/get-organization-info.ts similarity index 98% rename from packages/identite/src/organization/get-organization-info.ts rename to packages/identite/src/managers/organization/get-organization-info.ts index 0090e3175..9687434f2 100644 --- a/packages/identite/src/organization/get-organization-info.ts +++ b/packages/identite/src/managers/organization/get-organization-info.ts @@ -1,4 +1,4 @@ -import type { OrganizationInfo } from "@gouvfr-lasuite/proconnect.identite/types"; +import type { OrganizationInfo } from "#src/types"; import { type FindBySirenHandler, type FindBySiretHandler, diff --git a/packages/identite/src/organization/index.ts b/packages/identite/src/managers/organization/index.ts similarity index 51% rename from packages/identite/src/organization/index.ts rename to packages/identite/src/managers/organization/index.ts index 4ee2827f7..a13e1ebe4 100644 --- a/packages/identite/src/organization/index.ts +++ b/packages/identite/src/managers/organization/index.ts @@ -1,4 +1,4 @@ // export * from "./get-organization-info.js"; -export * from "./upsert.js"; +export * from "./mark-domain-as-verified.js"; diff --git a/packages/identite/src/managers/organization/mark-domain-as-verified.test.ts b/packages/identite/src/managers/organization/mark-domain-as-verified.test.ts new file mode 100644 index 000000000..01c3bcf4d --- /dev/null +++ b/packages/identite/src/managers/organization/mark-domain-as-verified.test.ts @@ -0,0 +1,115 @@ +import { + type AddDomainHandler, + type FindEmailDomainsByOrganizationIdHandler, +} from "#src/repositories/email-domain"; +import type { + FindByIdHandler, + GetUsersByOrganizationHandler, +} from "#src/repositories/organization"; +import type { UpdateUserOrganizationLinkHandler } from "#src/repositories/user"; +import type { + BaseUserOrganizationLink, + EmailDomain, + Organization, + User, + UserOrganizationLink, +} from "#src/types"; +import * as chai from "chai"; +import chaiAsPromised from "chai-as-promised"; +import { mock } from "node:test"; +import { markDomainAsVerifiedFactory } from "./mark-domain-as-verified.js"; +// + +chai.use(chaiAsPromised); +const assert = chai.assert; + +describe(markDomainAsVerifiedFactory.name, () => { + it("should update organization members", async () => { + const addDomain = mock.fn(() => + Promise.resolve({} as any), + ); + + const updateUserOrganizationLink = + mock.fn(() => + Promise.resolve({} as any), + ); + const markDomainAsVerified = markDomainAsVerifiedFactory({ + addDomain, + findEmailDomainsByOrganizationId: + mock.fn(), + findOrganizationById: mock.fn(() => + Promise.resolve({ id: 42 } as Organization), + ), + getUsers: mock.fn(() => + Promise.resolve([ + { + id: 42, + email: "lion.eljonson@darkangels.world", + verification_type: null, + } as User & BaseUserOrganizationLink, + ]), + ), + updateUserOrganizationLink, + }); + + await markDomainAsVerified({ + domain: "darkangels.world", + domain_verification_type: "verified", + organization_id: 42, + }); + + assert.deepEqual(updateUserOrganizationLink.mock.callCount(), 1); + { + const [call] = updateUserOrganizationLink.mock.calls; + assert.deepEqual(call.arguments, [ + 42, + 42, + { verification_type: "domain" }, + ]); + } + + assert.deepEqual(addDomain.mock.callCount(), 1); + { + const [call] = addDomain.mock.calls; + assert.deepEqual(call.arguments, [ + { + domain: "darkangels.world", + organization_id: 42, + verification_type: "verified", + }, + ]); + } + }); + + it("should add domain if organization if missing", async () => { + const logs = [] as unknown[]; + const updateUserOrganizationLink: UpdateUserOrganizationLinkHandler = ( + ...args + ) => { + logs.push(args); + return Promise.resolve({} as UserOrganizationLink); + }; + const markDomainAsVerified = markDomainAsVerifiedFactory({ + addDomain: () => Promise.resolve({} as EmailDomain), + findEmailDomainsByOrganizationId: () => Promise.resolve([]), + findOrganizationById: () => Promise.resolve({ id: 42 } as Organization), + getUsers: () => + Promise.resolve([ + { + id: 42, + email: "lion.eljonson@darkangels.world", + verification_type: null, + } as User & BaseUserOrganizationLink, + ]), + updateUserOrganizationLink, + }); + + await markDomainAsVerified({ + domain: "darkangels.world", + domain_verification_type: "verified", + organization_id: 42, + }); + + assert.deepEqual(logs, [[42, 42, { verification_type: "domain" }]]); + }); +}); diff --git a/packages/identite/src/managers/organization/mark-domain-as-verified.ts b/packages/identite/src/managers/organization/mark-domain-as-verified.ts new file mode 100644 index 000000000..c187d48eb --- /dev/null +++ b/packages/identite/src/managers/organization/mark-domain-as-verified.ts @@ -0,0 +1,84 @@ +// + +import type { GetUsersByOrganizationHandler } from "#src/repositories/organization"; +import type { UpdateUserOrganizationLinkHandler } from "#src/repositories/user"; +import { getEmailDomain } from "@gouvfr-lasuite/proconnect.core/services/email"; +import type { + AddDomainHandler, + FindEmailDomainsByOrganizationIdHandler, +} from "@gouvfr-lasuite/proconnect.identite/repositories/email-domain"; +import type { FindByIdHandler } from "@gouvfr-lasuite/proconnect.identite/repositories/organization"; +import type { EmailDomain } from "@gouvfr-lasuite/proconnect.identite/types"; +import { InseeNotFoundError } from "@gouvfr-lasuite/proconnect.insee/errors"; +import { isEmpty, some } from "lodash-es"; + +// + +type FactoryDependencies = { + addDomain: AddDomainHandler; + findEmailDomainsByOrganizationId: FindEmailDomainsByOrganizationIdHandler; + findOrganizationById: FindByIdHandler; + getUsers: GetUsersByOrganizationHandler; + updateUserOrganizationLink: UpdateUserOrganizationLinkHandler; +}; + +export function markDomainAsVerifiedFactory({ + addDomain, + findEmailDomainsByOrganizationId, + findOrganizationById, + getUsers, + updateUserOrganizationLink, +}: FactoryDependencies) { + return async function markDomainAsVerified({ + organization_id, + domain, + domain_verification_type, + }: { + organization_id: number; + domain: string; + domain_verification_type: EmailDomain["verification_type"]; + }) { + const organization = await findOrganizationById(organization_id); + if (isEmpty(organization)) { + throw new InseeNotFoundError(); + } + const emailDomains = + await findEmailDomainsByOrganizationId(organization_id); + + if ( + !some(emailDomains, { + domain, + verification_type: domain_verification_type, + }) + ) { + await addDomain({ + organization_id, + domain, + verification_type: domain_verification_type, + }); + } + const usersInOrganization = await getUsers(organization_id); + + await Promise.all( + usersInOrganization.map( + ({ id, email, verification_type: link_verification_type }) => { + const userDomain = getEmailDomain(email); + if ( + userDomain === domain && + [ + null, + "no_verification_means_available", + "no_verification_means_for_entreprise_unipersonnelle", + ].includes(link_verification_type) + ) { + return updateUserOrganizationLink(organization_id, id, { + verification_type: "domain", + }); + } + + return null; + }, + ), + ); + }; +} diff --git a/packages/identite/src/repositories/email-domain/add-domain.test.ts b/packages/identite/src/repositories/email-domain/add-domain.test.ts new file mode 100644 index 000000000..c8c451ec1 --- /dev/null +++ b/packages/identite/src/repositories/email-domain/add-domain.test.ts @@ -0,0 +1,35 @@ +// + +import { emptyDatabase, migrate, pg } from "#testing"; +import { expect } from "chai"; +import { before, describe, it } from "mocha"; +import { addDomainFactory } from "./add-domain.js"; + +// + +const addDomain = addDomainFactory({ pg: pg as any }); + +describe(addDomainFactory.name, () => { + before(migrate); + beforeEach(emptyDatabase); + + it("should add domain", async () => { + await pg.sql` + INSERT INTO organizations + (id, siret, created_at, updated_at) + VALUES + (1, '66204244933106', '4444-04-04', '4444-04-04') + ; + `; + + const emailDomain = await addDomain({ + domain: "darkangels.world", + organization_id: 1, + verification_type: "verified", + }); + + expect(emailDomain.domain).to.equal("darkangels.world"); + expect(emailDomain.verification_type).to.equal("verified"); + expect(emailDomain.created_at).to.deep.equal(emailDomain.updated_at); + }); +}); diff --git a/packages/identite/src/repositories/email-domain/add-domain.ts b/packages/identite/src/repositories/email-domain/add-domain.ts new file mode 100644 index 000000000..27ddd258e --- /dev/null +++ b/packages/identite/src/repositories/email-domain/add-domain.ts @@ -0,0 +1,48 @@ +// + +import { hashToPostgresParams } from "#src/services"; +import type { DatabaseContext, EmailDomain } from "#src/types"; +import type { QueryResult } from "pg"; + +// + +export function addDomainFactory({ pg }: DatabaseContext) { + return async function addDomain({ + organization_id, + domain, + verification_type, + }: { + organization_id: number; + domain: string; + verification_type: EmailDomain["verification_type"]; + }) { + const connection = pg; + + const emailDomain = { + organization_id, + domain, + verification_type, + can_be_suggested: true, + verified_at: new Date(), + created_at: new Date(), + updated_at: new Date(), + }; + + const { paramsString, valuesString, values } = + hashToPostgresParams(emailDomain); + + const { rows }: QueryResult = await connection.query( + ` + INSERT INTO email_domains + ${paramsString} + VALUES + ${valuesString} + RETURNING *;`, + values, + ); + + return rows.shift()!; + }; +} + +export type AddDomainHandler = ReturnType; diff --git a/packages/identite/src/repositories/email-domain/find-email-domains-by-organization-id.test.ts b/packages/identite/src/repositories/email-domain/find-email-domains-by-organization-id.test.ts new file mode 100644 index 000000000..e6153fc15 --- /dev/null +++ b/packages/identite/src/repositories/email-domain/find-email-domains-by-organization-id.test.ts @@ -0,0 +1,55 @@ +// + +import { emptyDatabase, migrate, pg } from "#testing"; +import { expect } from "chai"; +import { before, describe, it } from "mocha"; +import { findEmailDomainsByOrganizationIdFactory } from "./find-email-domains-by-organization-id.js"; + +// + +const findEmailDomainsByOrganizationId = + findEmailDomainsByOrganizationIdFactory({ pg: pg as any }); + +describe(findEmailDomainsByOrganizationIdFactory.name, () => { + before(migrate); + beforeEach(emptyDatabase); + + it("should find email domains by organization id", async () => { + await pg.sql` + INSERT INTO organizations + (id, siret, created_at, updated_at) + VALUES + (1, '66204244933106', '4444-04-04', '4444-04-04') + ; + `; + + await pg.sql` + INSERT INTO email_domains + (id, domain, organization_id, created_at, updated_at) + VALUES + (1, 'darkangels.world', 1, '4444-04-04', '4444-04-04') + ; + `; + + const emailDomains = await findEmailDomainsByOrganizationId(1); + + expect(emailDomains).to.deep.equal([ + { + can_be_suggested: true, + created_at: new Date("4444-04-04"), + domain: "darkangels.world", + id: 1, + organization_id: 1, + updated_at: new Date("4444-04-04"), + verification_type: null, + verified_at: null, + }, + ]); + }); + + it("❎ fail to find the organization 42", async () => { + const user = await findEmailDomainsByOrganizationId(42); + + expect(user).to.be.deep.equal([]); + }); +}); diff --git a/packages/identite/src/repositories/email-domain/find-email-domains-by-organization-id.ts b/packages/identite/src/repositories/email-domain/find-email-domains-by-organization-id.ts new file mode 100644 index 000000000..8472b6a71 --- /dev/null +++ b/packages/identite/src/repositories/email-domain/find-email-domains-by-organization-id.ts @@ -0,0 +1,28 @@ +// + +import type { DatabaseContext, EmailDomain } from "#src/types"; +import type { QueryResult } from "pg"; + +// + +export function findEmailDomainsByOrganizationIdFactory({ + pg, +}: DatabaseContext) { + return async function findEmailDomainsByOrganizationId( + organization_id: number, + ) { + const { rows }: QueryResult = await pg.query( + ` + SELECT * + FROM email_domains + WHERE organization_id = $1`, + [organization_id], + ); + + return rows; + }; +} + +export type FindEmailDomainsByOrganizationIdHandler = ReturnType< + typeof findEmailDomainsByOrganizationIdFactory +>; diff --git a/packages/identite/src/repositories/email-domain/index.ts b/packages/identite/src/repositories/email-domain/index.ts new file mode 100644 index 000000000..d71a7e7b1 --- /dev/null +++ b/packages/identite/src/repositories/email-domain/index.ts @@ -0,0 +1,2 @@ +export * from "./add-domain.js"; +export * from "./find-email-domains-by-organization-id.js"; diff --git a/packages/identite/src/repositories/organization/find-by-id.test.ts b/packages/identite/src/repositories/organization/find-by-id.test.ts new file mode 100644 index 000000000..98392fb8f --- /dev/null +++ b/packages/identite/src/repositories/organization/find-by-id.test.ts @@ -0,0 +1,51 @@ +// + +import { emptyDatabase, migrate, pg } from "#testing"; +import { expect } from "chai"; +import { before, describe, it } from "mocha"; +import { findByIdFactory } from "./find-by-id.js"; + +// + +const findById = findByIdFactory({ pg: pg as any }); + +describe(findByIdFactory.name, () => { + before(migrate); + beforeEach(emptyDatabase); + + it("should find the Necron organization", async () => { + await pg.sql` + INSERT INTO organizations + (cached_libelle, cached_nom_complet, id, siret, created_at, updated_at) + VALUES + ('Necron', 'Necrontyr', 1, '⚰️', '1967-12-19', '1967-12-19') + ; + `; + const organization = await findById(1); + + expect(organization).to.deep.equal({ + cached_activite_principale: null, + cached_adresse: null, + cached_categorie_juridique: null, + cached_code_officiel_geographique: null, + cached_code_postal: null, + cached_enseigne: null, + cached_est_active: null, + cached_est_diffusible: null, + cached_etat_administratif: null, + cached_libelle_activite_principale: null, + cached_libelle_categorie_juridique: null, + cached_libelle_tranche_effectif: null, + cached_libelle: "Necron", + cached_nom_complet: "Necrontyr", + cached_statut_diffusion: null, + cached_tranche_effectifs_unite_legale: null, + cached_tranche_effectifs: null, + created_at: new Date("1967-12-19"), + organization_info_fetched_at: null, + id: 1, + siret: "⚰️", + updated_at: new Date("1967-12-19"), + }); + }); +}); diff --git a/packages/identite/src/repositories/organization/find-by-id.ts b/packages/identite/src/repositories/organization/find-by-id.ts new file mode 100644 index 000000000..5748dd40d --- /dev/null +++ b/packages/identite/src/repositories/organization/find-by-id.ts @@ -0,0 +1,22 @@ +// + +import type { DatabaseContext, Organization } from "#src/types"; +import type { QueryResult } from "pg"; + +// + +export function findByIdFactory({ pg }: DatabaseContext) { + return async function findById(id: number) { + const { rows }: QueryResult = await pg.query( + ` + SELECT * + FROM organizations + WHERE id = $1`, + [id], + ); + + return rows.shift(); + }; +} + +export type FindByIdHandler = ReturnType; diff --git a/packages/identite/src/repositories/organization/get-users-by-organization.test.ts b/packages/identite/src/repositories/organization/get-users-by-organization.test.ts new file mode 100644 index 000000000..39904ac59 --- /dev/null +++ b/packages/identite/src/repositories/organization/get-users-by-organization.test.ts @@ -0,0 +1,85 @@ +// + +import { emptyDatabase, migrate, pg } from "#testing"; +import { expect } from "chai"; +import { before, describe, it } from "mocha"; +import { getUsersByOrganizationFactory } from "./get-users-by-organization.js"; + +// + +const getUsersByOrganization = getUsersByOrganizationFactory({ pg: pg as any }); + +describe(getUsersByOrganizationFactory.name, () => { + before(migrate); + beforeEach(emptyDatabase); + + it("should find users by organization id", async () => { + await pg.sql` + INSERT INTO organizations + (cached_libelle, cached_nom_complet, id, siret, created_at, updated_at) + VALUES + ('Necron', 'Necrontyr', 1, '⚰️', '1967-12-19', '1967-12-19') + ; + `; + + await pg.sql` + INSERT INTO users + (id, email, created_at, updated_at, given_name, family_name, phone_number, job) + VALUES + (1, 'lion.eljonson@darkangels.world', '4444-04-04', '4444-04-04', 'lion', 'el''jonson', 'i', 'primarque') + ; + `; + + await pg.sql` + INSERT INTO users_organizations + (user_id, organization_id, created_at, updated_at, is_external, verification_type, needs_official_contact_email_verification, official_contact_email_verification_token, official_contact_email_verification_sent_at) + VALUES + (1, 1, '4444-04-04', '4444-04-04', false, 'no_verification_means_available', false, null, null) + ; + `; + + const user = await getUsersByOrganization(1); + + expect(user).to.deep.equal([ + { + created_at: new Date("4444-04-04"), + current_challenge: null, + email_verified_at: null, + email_verified: false, + email: "lion.eljonson@darkangels.world", + encrypted_password: "", + encrypted_totp_key: null, + family_name: "el'jonson", + force_2fa: false, + given_name: "lion", + has_been_greeted: false, + id: 1, + is_external: false, + job: "primarque", + last_sign_in_at: null, + magic_link_sent_at: null, + magic_link_token: null, + needs_inclusionconnect_onboarding_help: false, + needs_inclusionconnect_welcome_page: false, + needs_official_contact_email_verification: false, + official_contact_email_verification_sent_at: null, + official_contact_email_verification_token: null, + phone_number: "i", + reset_password_sent_at: null, + reset_password_token: null, + sign_in_count: 0, + totp_key_verified_at: null, + updated_at: new Date("4444-04-04"), + verification_type: "no_verification_means_available", + verify_email_sent_at: null, + verify_email_token: null, + }, + ]); + }); + + it("❎ fail to find users for unknown organization id", async () => { + const user = await getUsersByOrganization(42); + + expect(user).to.deep.equal([]); + }); +}); diff --git a/packages/identite/src/repositories/organization/get-users-by-organization.ts b/packages/identite/src/repositories/organization/get-users-by-organization.ts new file mode 100644 index 000000000..b29ae2c38 --- /dev/null +++ b/packages/identite/src/repositories/organization/get-users-by-organization.ts @@ -0,0 +1,45 @@ +// + +import type { + BaseUserOrganizationLink, + DatabaseContext, + User, +} from "#src/types"; +import type { QueryResult } from "pg"; + +// + +export function getUsersByOrganizationFactory({ pg }: DatabaseContext) { + return async function getUsersByOrganization( + organization_id: number, + additionalWhereClause: string = "", + additionalParams: any[] = [], + ) { + const connection = pg; + const baseParams = [organization_id]; + + const { rows }: QueryResult = + await connection.query( + ` + SELECT + u.*, + uo.is_external, + uo.verification_type, + uo.has_been_greeted, + uo.needs_official_contact_email_verification, + uo.official_contact_email_verification_token, + uo.official_contact_email_verification_sent_at + FROM users u + INNER JOIN users_organizations AS uo ON uo.user_id = u.id + WHERE uo.organization_id = $1 + ${additionalWhereClause}`, + [...baseParams, ...additionalParams], + ); + + return rows; + }; +} + +export type GetUsersByOrganizationHandler = ReturnType< + typeof getUsersByOrganizationFactory +>; diff --git a/packages/identite/src/repositories/organization/index.ts b/packages/identite/src/repositories/organization/index.ts new file mode 100644 index 000000000..7f11f8901 --- /dev/null +++ b/packages/identite/src/repositories/organization/index.ts @@ -0,0 +1,5 @@ +// + +export * from "./find-by-id.js"; +export * from "./get-users-by-organization.js"; +export * from "./upsert.js"; diff --git a/packages/identite/src/organization/upsert.ts b/packages/identite/src/repositories/organization/upsert.ts similarity index 100% rename from packages/identite/src/organization/upsert.ts rename to packages/identite/src/repositories/organization/upsert.ts diff --git a/packages/identite/src/organization/upset.test.ts b/packages/identite/src/repositories/organization/upset.test.ts similarity index 63% rename from packages/identite/src/organization/upset.test.ts rename to packages/identite/src/repositories/organization/upset.test.ts index c440652b8..5c2a4cdff 100644 --- a/packages/identite/src/organization/upset.test.ts +++ b/packages/identite/src/repositories/organization/upset.test.ts @@ -1,29 +1,18 @@ // -import { PGlite } from "@electric-sql/pglite"; +import { emptyDatabase, migrate, pg } from "#testing"; import { expect } from "chai"; -import { noop } from "lodash-es"; import { before, describe, it } from "mocha"; -import { runner } from "node-pg-migrate"; -import { join } from "path"; import { upsertFactory } from "./upsert.js"; // -const pg = new PGlite(); const upset = upsertFactory({ pg: pg as any }); -before(async function migrate() { - await runner({ - dbClient: pg as any, - dir: join(import.meta.dirname, "../../../../migrations"), - direction: "up", - migrationsTable: "pg-migrate", - log: noop, - }); -}); - describe("upset", () => { + before(migrate); + beforeEach(emptyDatabase); + it("should create the Tau Empire organization", async () => { const organization = await upset({ organizationInfo: { @@ -36,11 +25,12 @@ describe("upset", () => { }); it("should update the Necron organization", async () => { - await pg.sql`insert into organizations - (siret, created_at, updated_at) - VALUES - ('⚰️', '1967-12-19', '1967-12-19'); - `; + await pg.sql` + INSERT INTO organizations + (siret, created_at, updated_at) + VALUES + ('⚰️', '1967-12-19', '1967-12-19'); + `; const organization = await upset({ organizationInfo: { libelle: "Necron", diff --git a/packages/identite/src/repositories/user/create.test.ts b/packages/identite/src/repositories/user/create.test.ts new file mode 100644 index 000000000..2ecd06008 --- /dev/null +++ b/packages/identite/src/repositories/user/create.test.ts @@ -0,0 +1,20 @@ +// + +import { emptyDatabase, migrate, pg } from "#testing"; +import { expect } from "chai"; +import { before, describe, it } from "mocha"; +import { createUserFactory } from "./create.js"; + +// + +const createUser = createUserFactory({ pg: pg as any }); + +describe(createUserFactory.name, () => { + before(migrate); + beforeEach(emptyDatabase); + + it("should create the god-emperor of mankind", async () => { + const user = await createUser({ email: "god-emperor@mankind" }); + expect(user.email).to.equal("god-emperor@mankind"); + }); +}); diff --git a/packages/identite/src/user/create.ts b/packages/identite/src/repositories/user/create.ts similarity index 100% rename from packages/identite/src/user/create.ts rename to packages/identite/src/repositories/user/create.ts diff --git a/packages/identite/src/repositories/user/find-by-email.test.ts b/packages/identite/src/repositories/user/find-by-email.test.ts new file mode 100644 index 000000000..62caa8fac --- /dev/null +++ b/packages/identite/src/repositories/user/find-by-email.test.ts @@ -0,0 +1,36 @@ +// + +import { emptyDatabase, migrate, pg } from "#testing"; +import { expect } from "chai"; +import { before, describe, it } from "mocha"; +import { findByEmailFactory } from "./find-by-email.js"; + +// + +const findByEmail = findByEmailFactory({ pg: pg as any }); + +describe(findByEmailFactory.name, () => { + before(migrate); + beforeEach(emptyDatabase); + + it("should find a user by email", async () => { + await pg.sql` + INSERT INTO users + (id, email, created_at, updated_at, given_name, family_name, phone_number, job) + VALUES + (1, 'lion.eljonson@darkangels.world', '4444-04-04', '4444-04-04', 'lion', 'el''jonson', 'i', 'primarque'), + (2, 'perturabo@ironwarriors.world', '4444-04-04', '4444-04-04', 'lion', 'el''jonson', 'iv', 'primarque') + ; + `; + + const user = await findByEmail("lion.eljonson@darkangels.world"); + + expect(user?.email).to.equal("lion.eljonson@darkangels.world"); + }); + + it("❎ fail to find the God-Emperor of Mankind", async () => { + const user = await findByEmail("the God-Emperor of Mankind"); + + expect(user).to.be.undefined; + }); +}); diff --git a/packages/identite/src/user/find-by-email.ts b/packages/identite/src/repositories/user/find-by-email.ts similarity index 100% rename from packages/identite/src/user/find-by-email.ts rename to packages/identite/src/repositories/user/find-by-email.ts diff --git a/packages/identite/src/repositories/user/find-by-id.test.ts b/packages/identite/src/repositories/user/find-by-id.test.ts new file mode 100644 index 000000000..f37cbb058 --- /dev/null +++ b/packages/identite/src/repositories/user/find-by-id.test.ts @@ -0,0 +1,36 @@ +// + +import { emptyDatabase, migrate, pg } from "#testing"; +import { expect } from "chai"; +import { before, describe, it } from "mocha"; +import { findByIdFactory } from "./find-by-id.js"; + +// + +const findById = findByIdFactory({ pg: pg as any }); + +describe(findByIdFactory.name, () => { + before(migrate); + beforeEach(emptyDatabase); + + it("should find a user by id", async () => { + await pg.sql` + INSERT INTO users + (id, email, created_at, updated_at, given_name, family_name, phone_number, job) + VALUES + (1, 'lion.eljonson@darkangels.world', '4444-04-04', '4444-04-04', 'lion', 'el''jonson', 'i', 'primarque'), + (2, 'perturabo@ironwarriors.world', '4444-04-04', '4444-04-04', 'lion', 'el''jonson', 'iv', 'primarque') + ; + `; + + const user = await findById(1); + + expect(user?.email).to.equal("lion.eljonson@darkangels.world"); + }); + + it("❎ fail to find the God-Emperor of Mankind", async () => { + const user = await findById(42); + + expect(user).to.be.undefined; + }); +}); diff --git a/packages/identite/src/repositories/user/find-by-id.ts b/packages/identite/src/repositories/user/find-by-id.ts new file mode 100644 index 000000000..8b34c2994 --- /dev/null +++ b/packages/identite/src/repositories/user/find-by-id.ts @@ -0,0 +1,21 @@ +// + +import type { DatabaseContext, User } from "#src/types"; +import { type QueryResult } from "pg"; + +// + +export function findByIdFactory({ pg }: DatabaseContext) { + return async function findById(id: number) { + const { rows }: QueryResult = await pg.query( + ` + SELECT * + FROM users + WHERE id = $1 + `, + [id], + ); + + return rows.shift(); + }; +} diff --git a/packages/identite/src/user/index.ts b/packages/identite/src/repositories/user/index.ts similarity index 53% rename from packages/identite/src/user/index.ts rename to packages/identite/src/repositories/user/index.ts index 466f4bcea..862ed1617 100644 --- a/packages/identite/src/user/index.ts +++ b/packages/identite/src/repositories/user/index.ts @@ -2,4 +2,6 @@ export * from "./create.js"; export * from "./find-by-email.js"; +export * from "./find-by-id.js"; +export * from "./update-user-organization-link.js"; export * from "./update.js"; diff --git a/packages/identite/src/repositories/user/update-user-organization-link.test.ts b/packages/identite/src/repositories/user/update-user-organization-link.test.ts new file mode 100644 index 000000000..660899613 --- /dev/null +++ b/packages/identite/src/repositories/user/update-user-organization-link.test.ts @@ -0,0 +1,46 @@ +// + +import { emptyDatabase, migrate, pg } from "#testing"; +import { expect } from "chai"; +import { before, describe, it } from "mocha"; +import { updateUserOrganizationLinkFactory } from "./update-user-organization-link.js"; + +// + +const updateUserOrganizationLink = updateUserOrganizationLinkFactory({ + pg: pg as any, +}); + +describe(updateUserOrganizationLink.name, () => { + before(migrate); + beforeEach(emptyDatabase); + + it("should update the user organization link", async () => { + await pg.sql` + INSERT INTO organizations + (cached_libelle, cached_nom_complet, id, siret, created_at, updated_at) + VALUES + ('Necron', 'Necrontyr', 1, '⚰️', '1967-12-19', '1967-12-19') + ; + `; + await pg.sql` + INSERT INTO users + (id, email, created_at, updated_at, given_name, family_name, phone_number, job) + VALUES + (1, 'lion.eljonson@darkangels.world', '4444-04-04', '4444-04-04', 'Lion', 'El''Jonson', 'I', 'Primarque') + ; + `; + await pg.sql` + INSERT INTO users_organizations + (user_id, organization_id, created_at, updated_at, is_external, verification_type, needs_official_contact_email_verification, official_contact_email_verification_token, official_contact_email_verification_sent_at) + VALUES + (1, 1, '4444-04-04', '4444-04-04', false, 'no_verification_means_available', false, null, null) + ; + `; + + const user = await updateUserOrganizationLink(1, 1, { + is_external: true, + }); + expect(user.is_external).to.equal(true); + }); +}); diff --git a/packages/identite/src/repositories/user/update-user-organization-link.ts b/packages/identite/src/repositories/user/update-user-organization-link.ts new file mode 100644 index 000000000..e4ee207c0 --- /dev/null +++ b/packages/identite/src/repositories/user/update-user-organization-link.ts @@ -0,0 +1,42 @@ +// + +import { hashToPostgresParams } from "#src/services"; +import type { DatabaseContext, User, UserOrganizationLink } from "#src/types"; +import type { QueryResult } from "pg"; + +// + +export function updateUserOrganizationLinkFactory({ pg }: DatabaseContext) { + return async function updateUserOrganizationLink( + organization_id: number, + user_id: number, + fieldsToUpdate: Partial, + ) { + const connection = pg; + + const fieldsToUpdateWithTimestamps = { + ...fieldsToUpdate, + updated_at: new Date(), + }; + + const { paramsString, valuesString, values } = hashToPostgresParams( + fieldsToUpdateWithTimestamps, + ); + + const { rows }: QueryResult = await connection.query( + ` + UPDATE users_organizations SET ${paramsString} = ${valuesString} + WHERE organization_id = $${values.length + 1} + AND user_id = $${values.length + 2} + RETURNING * + `, + [...values, organization_id, user_id], + ); + + return rows.shift()!; + }; +} + +export type UpdateUserOrganizationLinkHandler = ReturnType< + typeof updateUserOrganizationLinkFactory +>; diff --git a/packages/identite/src/repositories/user/update.test.ts b/packages/identite/src/repositories/user/update.test.ts new file mode 100644 index 000000000..0a0da0ccc --- /dev/null +++ b/packages/identite/src/repositories/user/update.test.ts @@ -0,0 +1,26 @@ +// + +import { emptyDatabase, migrate, pg } from "#testing"; +import { expect } from "chai"; +import { before, describe, it } from "mocha"; +import { updateUserFactory } from "./update.js"; + +// + +const updateUser = updateUserFactory({ pg: pg as any }); + +describe(updateUserFactory.name, () => { + before(migrate); + beforeEach(emptyDatabase); + + it("should update the user job", async () => { + await pg.sql` + INSERT INTO users + (id, email, created_at, updated_at, given_name, family_name, phone_number, job) + VALUES + (1, 'lion.eljonson@darkangels.world', '4444-04-04', '4444-04-04', 'Lion', 'El''Jonson', 'I', 'Primarque'); + `; + const user = await updateUser(1, { job: "Chevalier de l'Ordre" }); + expect(user.job).to.equal("Chevalier de l'Ordre"); + }); +}); diff --git a/packages/identite/src/user/update.ts b/packages/identite/src/repositories/user/update.ts similarity index 100% rename from packages/identite/src/user/update.ts rename to packages/identite/src/repositories/user/update.ts diff --git a/src/types/email-domain.d.ts b/packages/identite/src/types/email-domain.ts similarity index 92% rename from src/types/email-domain.d.ts rename to packages/identite/src/types/email-domain.ts index 10adc8a5e..b638a609c 100644 --- a/src/types/email-domain.d.ts +++ b/packages/identite/src/types/email-domain.ts @@ -1,4 +1,4 @@ -interface EmailDomain { +export interface EmailDomain { id: number; organization_id: number; domain: string; diff --git a/packages/identite/src/types/index.ts b/packages/identite/src/types/index.ts index 1c97d28bb..4c264d8e9 100644 --- a/packages/identite/src/types/index.ts +++ b/packages/identite/src/types/index.ts @@ -1,6 +1,8 @@ // export * from "./contexts.js"; +export * from "./email-domain.js"; export * from "./organization-info.js"; export * from "./organization.js"; +export * from "./user-organization-link.js"; export * from "./user.js"; diff --git a/src/types/user-organization-link.d.ts b/packages/identite/src/types/user-organization-link.ts similarity index 88% rename from src/types/user-organization-link.d.ts rename to packages/identite/src/types/user-organization-link.ts index 658f6a1b2..9b4480be8 100644 --- a/src/types/user-organization-link.d.ts +++ b/packages/identite/src/types/user-organization-link.ts @@ -1,4 +1,4 @@ -interface BaseUserOrganizationLink { +export interface BaseUserOrganizationLink { is_external: boolean; verification_type: | "code_sent_to_official_contact_email" @@ -21,7 +21,7 @@ interface BaseUserOrganizationLink { official_contact_email_verification_sent_at: Date | null; } -interface UserOrganizationLink extends BaseUserOrganizationLink { +export interface UserOrganizationLink extends BaseUserOrganizationLink { user_id: number; organization_id: number; created_at: Date; diff --git a/packages/identite/src/user/create.test.ts b/packages/identite/src/user/create.test.ts deleted file mode 100644 index 0396e2b26..000000000 --- a/packages/identite/src/user/create.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -// - -import { PGlite } from "@electric-sql/pglite"; -import { expect } from "chai"; -import { noop } from "lodash-es"; -import { before, describe, it } from "mocha"; -import { runner } from "node-pg-migrate"; -import { join } from "path"; -import { createUserFactory } from "./create.js"; - -// - -const pg = new PGlite(); -const createUser = createUserFactory({ pg: pg as any }); - -before(async function migrate() { - await runner({ - dbClient: pg as any, - dir: join(import.meta.dirname, "../../../../migrations"), - direction: "up", - log: noop, - migrationsTable: "pg-migrate", - }); -}); - -describe("CreateUser", () => { - it("should create the god-emperor of mankind", async () => { - const user = await createUser({ email: "god-emperor@mankind" }); - expect(user.email).to.equal("god-emperor@mankind"); - }); -}); diff --git a/packages/identite/src/user/find-by-email.test.ts b/packages/identite/src/user/find-by-email.test.ts deleted file mode 100644 index 03872c226..000000000 --- a/packages/identite/src/user/find-by-email.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -// - -import { PGlite } from "@electric-sql/pglite"; -import { expect } from "chai"; -import { noop } from "lodash-es"; -import { before, describe, it } from "mocha"; -import { runner } from "node-pg-migrate"; -import { join } from "path"; -import { findByEmailFactory } from "./find-by-email.js"; - -// - -const pg = new PGlite(); -const findByEmail = findByEmailFactory({ pg: pg as any }); - -before(async function migrate() { - await runner({ - dbClient: pg as any, - dir: join(import.meta.dirname, "../../../../migrations"), - direction: "up", - migrationsTable: "pg-migrate", - log: noop, - }); -}); - -describe("FindByEmail", () => { - it("should find a user by email", async () => { - await pg.sql`insert into users - (id, email, created_at, updated_at, given_name, family_name, phone_number, job) - values - (1, 'lion.eljonson@darkangels.world', '4444-04-04', '4444-04-04', 'lion', 'el''jonson', 'i', 'primarque'), - (2, 'perturabo@ironwarriors.world', '4444-04-04', '4444-04-04', 'lion', 'el''jonson', 'iv', 'primarque'); - `; - - const user = await findByEmail("lion.eljonson@darkangels.world"); - - expect(user?.email).to.equal("lion.eljonson@darkangels.world"); - }); - - it("❎ fail to find the God-Emperor of Mankind", async () => { - const user = await findByEmail("the God-Emperor of Mankind"); - - expect(user).to.be.undefined; - }); -}); diff --git a/packages/identite/src/user/update.test.ts b/packages/identite/src/user/update.test.ts deleted file mode 100644 index 5bba1028a..000000000 --- a/packages/identite/src/user/update.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -// - -import { PGlite } from "@electric-sql/pglite"; -import { expect } from "chai"; -import { noop } from "lodash-es"; -import { before, describe, it } from "mocha"; -import { runner } from "node-pg-migrate"; -import { join } from "path"; -import { updateUserFactory } from "./update.js"; - -// - -const pg = new PGlite(); -const updateUser = updateUserFactory({ pg: pg as any }); - -before(async function migrate() { - await runner({ - dbClient: pg as any, - dir: join(import.meta.dirname, "../../../../migrations"), - direction: "up", - log: noop, - migrationsTable: "pg-migrate", - }); -}); - -describe("UpdateUser", () => { - it("should update the user job", async () => { - await pg.sql`INSERT INTO users - (id, email, created_at, updated_at, given_name, family_name, phone_number, job) - VALUES - (1, 'lion.eljonson@darkangels.world', '4444-04-04', '4444-04-04', 'Lion', 'El''Jonson', 'I', 'Primarque'); - `; - const user = await updateUser(1, { job: "Chevalier de l'Ordre" }); - expect(user.job).to.equal("Chevalier de l'Ordre"); - }); -}); diff --git a/packages/identite/testing/index.ts b/packages/identite/testing/index.ts new file mode 100644 index 000000000..4fb7c43a6 --- /dev/null +++ b/packages/identite/testing/index.ts @@ -0,0 +1,31 @@ +// + +import { PGlite } from "@electric-sql/pglite"; +import { noop } from "lodash-es"; +import { runner } from "node-pg-migrate"; +import { join } from "path"; + +// + +export const pg = new PGlite(); + +export function migrate() { + return runner({ + dbClient: pg as any, + dir: join(import.meta.dirname, "../../../migrations"), + direction: "up", + migrationsTable: "pg-migrate", + log: noop, + }); +} + +export async function emptyDatabase() { + await pg.sql`delete from users_organizations;`; + // + await pg.sql`delete from organizations;`; + await pg.sql`ALTER SEQUENCE organizations_id_seq RESTART WITH 1`; + await pg.sql`delete from users;`; + await pg.sql`ALTER SEQUENCE users_id_seq RESTART WITH 1`; + await pg.sql`delete from email_domains;`; + await pg.sql`ALTER SEQUENCE email_domains_id_seq RESTART WITH 1`; +} diff --git a/packages/identite/tsconfig.json b/packages/identite/tsconfig.json index cc69b7e38..de364b522 100644 --- a/packages/identite/tsconfig.json +++ b/packages/identite/tsconfig.json @@ -3,8 +3,8 @@ "declaration": true, "declarationMap": true, "outDir": "./dist", + "rootDir": "./src", "resolveJsonModule": true, - "rootDir": "src", "types": ["node"] }, "extends": "@gouvfr-lasuite/proconnect.devtools.typescript/base/tsconfig.json", @@ -12,5 +12,5 @@ { "path": "../core/tsconfig.lib.json" }, { "path": "../insee/tsconfig.lib.json" } ], - "include": ["./src/**/*", "./src/**/*.json"] + "include": ["./testing/index.ts", "./src/**/*", "./src/**/*.json"] } diff --git a/packages/identite/tsconfig.lib.json b/packages/identite/tsconfig.lib.json index 3ba354418..1dcd41bd8 100644 --- a/packages/identite/tsconfig.lib.json +++ b/packages/identite/tsconfig.lib.json @@ -3,7 +3,7 @@ "outDir": "./dist", "rootDir": "./src" }, - "exclude": ["src/**/*.test.ts"], + "exclude": ["src/**/*.test.ts", "testing"], "extends": "./tsconfig.json", "include": ["src"] } diff --git a/src/connectors/api-sirene.ts b/src/connectors/api-sirene.ts index 17076d0f8..a84d33ac8 100644 --- a/src/connectors/api-sirene.ts +++ b/src/connectors/api-sirene.ts @@ -1,6 +1,6 @@ // -import { getOrganizationInfoFactory } from "@gouvfr-lasuite/proconnect.identite/organization"; +import { getOrganizationInfoFactory } from "@gouvfr-lasuite/proconnect.identite/managers/organization"; import { findBySirenFactory, findBySiretFactory, diff --git a/src/connectors/debounce.ts b/src/connectors/debounce.ts index 7491399a4..06c518fe1 100644 --- a/src/connectors/debounce.ts +++ b/src/connectors/debounce.ts @@ -1,3 +1,4 @@ +import { getEmailDomain } from "@gouvfr-lasuite/proconnect.core/services/email"; import { singleValidationFactory } from "@gouvfr-lasuite/proconnect.debounce/api"; import { DEBOUNCE_API_KEY, @@ -5,7 +6,6 @@ import { FEATURE_CHECK_EMAIL_DELIVERABILITY, HTTP_CLIENT_TIMEOUT, } from "../config/env"; -import { getEmailDomain } from "../services/email"; import { logger } from "../services/log"; type EmailDebounceInfo = { diff --git a/src/controllers/organization.ts b/src/controllers/organization.ts index fdcae691d..e7d69be6f 100644 --- a/src/controllers/organization.ts +++ b/src/controllers/organization.ts @@ -1,3 +1,4 @@ +import { getEmailDomain } from "@gouvfr-lasuite/proconnect.core/services/email"; import type { NextFunction, Request, Response } from "express"; import HttpErrors from "http-errors"; import { isEmpty } from "lodash-es"; @@ -30,7 +31,6 @@ import { optionalBooleanSchema, siretSchema, } from "../services/custom-zod-schemas"; -import { getEmailDomain } from "../services/email"; import getNotificationsFromRequest from "../services/get-notifications-from-request"; import hasErrorFromField from "../services/has-error-from-field"; diff --git a/src/managers/organization/join.ts b/src/managers/organization/join.ts index af17259bd..5c19badb3 100644 --- a/src/managers/organization/join.ts +++ b/src/managers/organization/join.ts @@ -1,8 +1,11 @@ import { isEmailValid } from "@gouvfr-lasuite/proconnect.core/security"; +import { getEmailDomain } from "@gouvfr-lasuite/proconnect.core/services/email"; import { Welcome } from "@gouvfr-lasuite/proconnect.email"; import type { + BaseUserOrganizationLink, Organization, OrganizationInfo, + UserOrganizationLink, } from "@gouvfr-lasuite/proconnect.identite/types"; import * as Sentry from "@sentry/node"; import { isEmpty, some } from "lodash-es"; @@ -45,7 +48,6 @@ import { } from "../../repositories/organization/setters"; import { findById as findUserById } from "../../repositories/user"; import { - getEmailDomain, isAFreeEmailProvider, usesAFreeEmailProvider, } from "../../services/email"; diff --git a/src/managers/organization/main.ts b/src/managers/organization/main.ts index 6a676aa70..5f9eef92e 100644 --- a/src/managers/organization/main.ts +++ b/src/managers/organization/main.ts @@ -1,5 +1,6 @@ +import { markDomainAsVerifiedFactory } from "@gouvfr-lasuite/proconnect.identite/managers/organization"; import type { Organization } from "@gouvfr-lasuite/proconnect.identite/types"; -import { isEmpty, some } from "lodash-es"; +import { isEmpty } from "lodash-es"; import { NotFoundError } from "../../config/errors"; import { addDomain, @@ -16,7 +17,6 @@ import { updateUserOrganizationLink, } from "../../repositories/organization/setters"; import { setSelectedOrganizationId } from "../../repositories/redis/selected-organization"; -import { getEmailDomain } from "../../services/email"; export const getOrganizationsByUserId = findByUserId; export const getOrganizationById = findOrganizationById; @@ -51,55 +51,14 @@ export const quitOrganization = async ({ return true; }; -export const markDomainAsVerified = async ({ - organization_id, - domain, - domain_verification_type, -}: { - organization_id: number; - domain: string; - domain_verification_type: EmailDomain["verification_type"]; -}) => { - const organization = await findOrganizationById(organization_id); - if (isEmpty(organization)) { - throw new NotFoundError(); - } - const emailDomains = await findEmailDomainsByOrganizationId(organization_id); - - if ( - !some(emailDomains, { domain, verification_type: domain_verification_type }) - ) { - await addDomain({ - organization_id, - domain, - verification_type: domain_verification_type, - }); - } - const usersInOrganization = await getUsers(organization_id); - - await Promise.all( - usersInOrganization.map( - ({ id, email, verification_type: link_verification_type }) => { - const userDomain = getEmailDomain(email); - if ( - userDomain === domain && - [ - null, - "no_verification_means_available", - "no_verification_means_for_entreprise_unipersonnelle", - ].includes(link_verification_type) - ) { - return updateUserOrganizationLink(organization_id, id, { - verification_type: "domain", - }); - } - - return null; - }, - ), - ); -}; +export const markDomainAsVerified = markDomainAsVerifiedFactory({ + addDomain, + findEmailDomainsByOrganizationId, + findOrganizationById, + getUsers, + updateUserOrganizationLink, +}); export const selectOrganization = async ({ user_id, diff --git a/src/managers/organization/official-contact-email-verification.ts b/src/managers/organization/official-contact-email-verification.ts index 76a75f714..a6857be28 100644 --- a/src/managers/organization/official-contact-email-verification.ts +++ b/src/managers/organization/official-contact-email-verification.ts @@ -1,5 +1,6 @@ import { generateDicewarePassword } from "@gouvfr-lasuite/proconnect.core/security"; import { OfficialContactEmailVerification } from "@gouvfr-lasuite/proconnect.email"; +import type { UserOrganizationLink } from "@gouvfr-lasuite/proconnect.identite/types"; import { isEmpty } from "lodash-es"; import { HOST } from "../../config/env"; import { diff --git a/src/repositories/email-domain.ts b/src/repositories/email-domain.ts index b21f78c38..b587e2407 100644 --- a/src/repositories/email-domain.ts +++ b/src/repositories/email-domain.ts @@ -1,51 +1,15 @@ -import type { QueryResult } from "pg"; +import { + addDomainFactory, + findEmailDomainsByOrganizationIdFactory, + type AddDomainHandler, +} from "@gouvfr-lasuite/proconnect.identite/repositories/email-domain"; import { getDatabaseConnection } from "../connectors/postgres"; -import { hashToPostgresParams } from "../services/hash-to-postgres-params"; -export const findEmailDomainsByOrganizationId = async ( - organization_id: number, -) => { - const connection = getDatabaseConnection(); +export const findEmailDomainsByOrganizationId = + findEmailDomainsByOrganizationIdFactory({ + pg: getDatabaseConnection(), + }); - const { rows }: QueryResult = await connection.query( - ` - SELECT * - FROM email_domains - WHERE organization_id = $1`, - [organization_id], - ); - - return rows; -}; - -export const addDomain = async ({ - organization_id, - domain, - verification_type, -}: { - organization_id: number; - domain: string; - verification_type: EmailDomain["verification_type"]; -}) => { - const connection = getDatabaseConnection(); - - const emailDomain = { - organization_id, - domain, - verification_type, - can_be_suggested: true, - verified_at: new Date(), - created_at: new Date(), - updated_at: new Date(), - }; - - const { paramsString, valuesString, values } = - hashToPostgresParams(emailDomain); - - const { rows }: QueryResult = await connection.query( - `INSERT INTO email_domains ${paramsString} VALUES ${valuesString} RETURNING *;`, - values, - ); - - return rows.shift()!; -}; +export const addDomain: AddDomainHandler = addDomainFactory({ + pg: getDatabaseConnection(), +}); diff --git a/src/repositories/organization/getters.ts b/src/repositories/organization/getters.ts index 7d66fa2eb..1606c74f6 100644 --- a/src/repositories/organization/getters.ts +++ b/src/repositories/organization/getters.ts @@ -1,6 +1,8 @@ +import { getUsersByOrganizationFactory } from "@gouvfr-lasuite/proconnect.identite/repositories/organization"; import type { + BaseUserOrganizationLink, Organization, - User, + UserOrganizationLink, } from "@gouvfr-lasuite/proconnect.identite/types"; import type { QueryResult } from "pg"; import { getDatabaseConnection } from "../../connectors/postgres"; @@ -85,37 +87,9 @@ ORDER BY member_count desc NULLS LAST;`, return rows; }; -export const getUsersByOrganization = async ( - organization_id: number, - additionalWhereClause: string = "", - additionalParams: any[] = [], -) => { - const connection = getDatabaseConnection(); - const baseParams = [organization_id]; - - const { rows }: QueryResult = - await connection.query( - ` -SELECT - u.*, - uo.is_external, - uo.verification_type, - uo.has_been_greeted, - uo.needs_official_contact_email_verification, - uo.official_contact_email_verification_token, - uo.official_contact_email_verification_sent_at -FROM users u -INNER JOIN users_organizations AS uo ON uo.user_id = u.id -WHERE uo.organization_id = $1 -${additionalWhereClause}`, - [...baseParams, ...additionalParams], - ); - - return rows; -}; - -export const getUsers = (organization_id: number) => - getUsersByOrganization(organization_id); +export const getUsers = getUsersByOrganizationFactory({ + pg: getDatabaseConnection(), +}); export const getUserOrganizationLink = async ( organization_id: number, diff --git a/src/repositories/organization/setters.ts b/src/repositories/organization/setters.ts index 3429b39d7..2750cbe79 100644 --- a/src/repositories/organization/setters.ts +++ b/src/repositories/organization/setters.ts @@ -1,8 +1,8 @@ -import { upsertFactory } from "@gouvfr-lasuite/proconnect.identite/organization"; -import type { User } from "@gouvfr-lasuite/proconnect.identite/types"; +import { upsertFactory } from "@gouvfr-lasuite/proconnect.identite/repositories/organization"; +import { updateUserOrganizationLinkFactory } from "@gouvfr-lasuite/proconnect.identite/repositories/user"; +import type { UserOrganizationLink } from "@gouvfr-lasuite/proconnect.identite/types"; import type { QueryResult } from "pg"; import { getDatabaseConnection } from "../../connectors/postgres"; -import { hashToPostgresParams } from "../../services/hash-to-postgres-params"; export const upsert = upsertFactory({ pg: getDatabaseConnection() }); @@ -48,32 +48,9 @@ RETURNING *`, return rows.shift()!; }; -export const updateUserOrganizationLink = async ( - organization_id: number, - user_id: number, - fieldsToUpdate: Partial, -) => { - const connection = getDatabaseConnection(); - - const fieldsToUpdateWithTimestamps = { - ...fieldsToUpdate, - updated_at: new Date(), - }; - - const { paramsString, valuesString, values } = hashToPostgresParams( - fieldsToUpdateWithTimestamps, - ); - - const { rows }: QueryResult = await connection.query( - ` -UPDATE users_organizations SET ${paramsString} = ${valuesString} -WHERE organization_id = $${values.length + 1} -AND user_id = $${values.length + 2} RETURNING *`, - [...values, organization_id, user_id], - ); - - return rows.shift()!; -}; +export const updateUserOrganizationLink = updateUserOrganizationLinkFactory({ + pg: getDatabaseConnection(), +}); export const deleteUserOrganization = async ({ user_id, diff --git a/src/repositories/user.ts b/src/repositories/user.ts index 75783d273..4cc4dbbff 100644 --- a/src/repositories/user.ts +++ b/src/repositories/user.ts @@ -1,24 +1,16 @@ -import type { User } from "@gouvfr-lasuite/proconnect.identite/types"; import { createUserFactory, findByEmailFactory, + findByIdFactory, updateUserFactory, -} from "@gouvfr-lasuite/proconnect.identite/user"; +} from "@gouvfr-lasuite/proconnect.identite/repositories/user"; +import type { User } from "@gouvfr-lasuite/proconnect.identite/types"; import type { QueryResult } from "pg"; import { getDatabaseConnection } from "../connectors/postgres"; -export const findById = async (id: number) => { - const connection = getDatabaseConnection(); - const { rows }: QueryResult = await connection.query( - ` -SELECT * -FROM users WHERE id = $1 -`, - [id], - ); +// - return rows.shift(); -}; +export const findById = findByIdFactory({ pg: getDatabaseConnection() }); export const findByEmail = findByEmailFactory({ pg: getDatabaseConnection() }); diff --git a/src/services/email.ts b/src/services/email.ts index 140939893..0971d3441 100644 --- a/src/services/email.ts +++ b/src/services/email.ts @@ -1,7 +1,9 @@ // -import { isAFreeDomain } from "@gouvfr-lasuite/proconnect.core/services/email"; -import { parse_host } from "tld-extract"; +import { + getEmailDomain, + isAFreeDomain, +} from "@gouvfr-lasuite/proconnect.core/services/email"; import { FEATURE_CONSIDER_ALL_EMAIL_DOMAINS_AS_FREE, FEATURE_CONSIDER_ALL_EMAIL_DOMAINS_AS_NON_FREE, @@ -19,14 +21,6 @@ export const isAFreeEmailProvider = (domain: string) => { return isAFreeDomain(domain); }; -export const getEmailDomain = (email: string) => { - const parts = email.split("@"); - const host = parts[parts.length - 1]; - const { sub, domain } = parse_host(host, { allowDotlessTLD: true }); - - return [sub, domain].filter((e) => !!e).join("."); -}; - export const usesAFreeEmailProvider = (email: string) => { const domain = getEmailDomain(email); diff --git a/src/types/tld-extract.d.ts b/src/types/tld-extract.d.ts deleted file mode 100644 index 35f56a7f9..000000000 --- a/src/types/tld-extract.d.ts +++ /dev/null @@ -1 +0,0 @@ -declare module "tld-extract"; diff --git a/test/email.test.ts b/test/email.test.ts index dd6921b1a..49abcfd3e 100644 --- a/test/email.test.ts +++ b/test/email.test.ts @@ -1,28 +1,5 @@ import { assert } from "chai"; -import { getEmailDomain, usesAFreeEmailProvider } from "../src/services/email"; - -describe("getEmailDomain", () => { - const data = [ - { - email: "user@beta.gouv.fr", - domain: "beta.gouv.fr", - }, - { - email: "user@notaires.fr", - domain: "notaires.fr", - }, - { - email: "user@subdomain.domain.org", - domain: "subdomain.domain.org", - }, - ]; - - data.forEach(({ email, domain }) => { - it("should return email domain", () => { - assert.equal(getEmailDomain(email), domain); - }); - }); -}); +import { usesAFreeEmailProvider } from "../src/services/email"; describe("usesAFreeEmailProvider", () => { const emailAddressesThatUsesFreeEmailProviders = [