diff --git a/back/src/domains/convention/adapters/InMemoryConventionQueries.ts b/back/src/domains/convention/adapters/InMemoryConventionQueries.ts index 89fc09c02c..01d9ccf793 100644 --- a/back/src/domains/convention/adapters/InMemoryConventionQueries.ts +++ b/back/src/domains/convention/adapters/InMemoryConventionQueries.ts @@ -6,6 +6,7 @@ import { ConventionId, ConventionReadDto, ConventionScope, + DataWithPagination, DateRange, FindSimilarConventionsParams, SiretDto, @@ -22,6 +23,7 @@ import { ConventionQueries, GetConventionsFilters, GetConventionsParams, + GetPaginatedConventionsForAgencyUserParams, } from "../ports/ConventionQueries"; import { InMemoryConventionRepository } from "./InMemoryConventionRepository"; @@ -119,6 +121,12 @@ export class InMemoryConventionQueries implements ConventionQueries { }); } + public async getPaginatedConventionsForAgencyUser( + params: GetPaginatedConventionsForAgencyUserParams, + ): Promise> { + throw new Error("Method not implemented."); + } + public async getConventionsByScope(params: { scope: ConventionScope; limit: number; diff --git a/back/src/domains/convention/adapters/PgConventionQueries.ts b/back/src/domains/convention/adapters/PgConventionQueries.ts index db2851a66a..2a75c536df 100644 --- a/back/src/domains/convention/adapters/PgConventionQueries.ts +++ b/back/src/domains/convention/adapters/PgConventionQueries.ts @@ -7,9 +7,14 @@ import { ConventionReadDto, ConventionScope, ConventionStatus, + DataWithPagination, + DateFilter, DateRange, FindSimilarConventionsParams, + NotEmptyArray, + PaginationQueryParams, SiretDto, + UserId, conventionReadSchema, conventionSchema, pipeWithValue, @@ -25,9 +30,12 @@ import { GetConventionsFilters, GetConventionsParams, GetConventionsSortBy, + GetPaginatedConventionsFilters, + GetPaginatedConventionsSortBy, } from "../ports/ConventionQueries"; import { createConventionQueryBuilder, + createConventionQueryBuilderForAgencyUser, getConventionAgencyFieldsForAgencies, getReadConventionById, } from "./pgConventionSql"; @@ -236,11 +244,157 @@ export class PgConventionQueries implements ConventionQueries { ); }); } + + public async getPaginatedConventionsForAgencyUser({ + filters = {}, + pagination, + sortBy, + agencyUserId, + }: { + agencyUserId: UserId; + pagination: Required; + filters?: GetPaginatedConventionsFilters; + sortBy: GetPaginatedConventionsSortBy; + }): Promise> { + const { + actorEmailContains, + beneficiaryNameContains, + establishmentNameContains, + statuses, + agencyIds, + agencyDepartmentCodes, + dateStart, + dateEnd, + dateSubmission, + ...rest + } = filters; + + rest satisfies Record; + + const data = await pipeWithValue( + createConventionQueryBuilderForAgencyUser({ + transaction: this.transaction, + agencyUserId, + }), + filterEmail(actorEmailContains?.trim()), + filterByBeneficiaryName(beneficiaryNameContains?.trim()), + filterEstablishmentName(establishmentNameContains?.trim()), + filterDate("date_start", dateStart), + filterDate("date_end", dateEnd), + filterDate("date_submission", dateSubmission), + filterInList("status", statuses), + filterInList("agency_id", agencyIds), + filterByAgencyDepartmentCodes(agencyDepartmentCodes), + sortConventions(sortBy), + (b) => b.limit(pagination.perPage), + ).execute(); + + return { + data: data.map(({ dto }) => dto), + pagination: { + currentPage: 1, + totalPages: 1, + numberPerPage: 1, + totalRecords: 1, + }, + }; + } } -type ConventionReadQueryBuilder = ReturnType< - typeof createConventionQueryBuilder ->; +type ConventionQueryBuilder = ReturnType; + +const sortConventions = + (sortBy?: GetPaginatedConventionsSortBy) => + (builder: ConventionQueryBuilder): ConventionQueryBuilder => { + const sortByByKey: Record = { + dateSubmission: "date_submission", + dateStart: "date_start", + dateValidation: "date_validation", + }; + + if (!sortBy) return builder.orderBy(sortByByKey.dateStart, "desc"); + return builder.orderBy(sortByByKey[sortBy], "desc"); + }; + +const filterByAgencyDepartmentCodes = + (agencyDepartmentCodes: NotEmptyArray | undefined) => + (builder: ConventionQueryBuilder): ConventionQueryBuilder => { + if (!agencyDepartmentCodes) return builder; + return builder.where( + "agencies.department_code", + "in", + agencyDepartmentCodes, + ); + }; + +const filterDate = + ( + fieldName: keyof Database["conventions"], + dateFilter: DateFilter | undefined, + ) => + (builder: ConventionQueryBuilder): ConventionQueryBuilder => { + if (!dateFilter) return builder; + if (dateFilter.from && dateFilter.to) + return builder + .where(fieldName, ">=", dateFilter.from) + .where(fieldName, "<=", dateFilter.to); + + if (dateFilter.from) return builder.where(fieldName, ">=", dateFilter.from); + if (dateFilter.to) return builder.where(fieldName, "<=", dateFilter.to); + return builder; + }; + +const filterEmail = + (emailContains: string | undefined) => + (builder: ConventionQueryBuilder): ConventionQueryBuilder => { + if (!emailContains) return builder; + return builder.where((eb) => + eb.or([ + eb("b.email", "ilike", sql`%${emailContains}%`), + eb("br.email", "ilike", sql`%${emailContains}%`), + eb("bce.email", "ilike", sql`%${emailContains}%`), + eb("er.email", "ilike", sql`%${emailContains}%`), + eb("et.email", "ilike", sql`%${emailContains}%`), + ]), + ); + }; + +const filterEstablishmentName = + (establishmentNameContains: string | undefined) => + (builder: ConventionQueryBuilder): ConventionQueryBuilder => { + if (!establishmentNameContains) return builder; + return builder.where( + "business_name", + "ilike", + `%${establishmentNameContains}%`, + ); + }; + +const filterByBeneficiaryName = + (beneficiaryNameContains: string | undefined) => + (builder: ConventionQueryBuilder): ConventionQueryBuilder => { + if (!beneficiaryNameContains) return builder; + const nameWords = beneficiaryNameContains.split(" "); + return builder.where((eb) => + eb.or( + nameWords.flatMap((nameWord) => [ + eb(eb.ref("b.first_name"), "ilike", `%${nameWord}%`), + eb(eb.ref("b.last_name"), "ilike", `%${nameWord}%`), + ]), + ), + + ); + }; + +const filterInList = + ( + fieldName: keyof Database["conventions"], + list: T[] | undefined, + ) => + (builder: ConventionQueryBuilder): ConventionQueryBuilder => { + if (!list) return builder; + return builder.where(fieldName, "in", list); + }; const addFiltersToBuilder = ({ @@ -251,7 +405,7 @@ const addFiltersToBuilder = dateSubmissionSince, withSirets, }: GetConventionsFilters) => - (builder: ConventionReadQueryBuilder) => { + (builder: ConventionQueryBuilder) => { const addWithStatusFilterIfNeeded: AddToBuilder = (b) => withStatuses && withStatuses.length > 0 ? b.where("conventions.status", "in", withStatuses) @@ -300,6 +454,4 @@ export const validateConventionResults = ( validateAndParseZodSchemaV2(conventionSchema, pgResult.dto, logger), ); -type AddToBuilder = ( - b: ConventionReadQueryBuilder, -) => ConventionReadQueryBuilder; +type AddToBuilder = (b: ConventionQueryBuilder) => ConventionQueryBuilder; diff --git a/back/src/domains/convention/adapters/pgConventionSql.ts b/back/src/domains/convention/adapters/pgConventionSql.ts index 069741774f..877be83774 100644 --- a/back/src/domains/convention/adapters/pgConventionSql.ts +++ b/back/src/domains/convention/adapters/pgConventionSql.ts @@ -1,4 +1,5 @@ import { sql } from "kysely"; +import { SelectQueryBuilder } from "kysely"; import { AgencyId, AgencyKind, @@ -7,6 +8,7 @@ import { AppellationLabel, Beneficiary, ConventionAgencyFields, + ConventionDto, ConventionId, ConventionReadDto, DateString, @@ -26,22 +28,23 @@ import { jsonBuildObject, jsonStripNulls, } from "../../../config/pg/kysely/kyselyUtils"; +import { Database } from "../../../config/pg/kysely/model/database"; import { createLogger } from "../../../utils/logger"; import { parseZodSchemaAndLogErrorOnParsingFailure } from "../../../utils/schema.utils"; -export const createConventionQueryBuilder = (transaction: KyselyDb) => { - // biome-ignore format: reads better without formatting - const builder = transaction - .selectFrom("conventions") - .innerJoin("actors as b", "b.id", "conventions.beneficiary_id") - .innerJoin("actors as er", "er.id", "conventions.establishment_representative_id") - .innerJoin("actors as et", "et.id", "conventions.establishment_tutor_id") - .leftJoin("actors as br", "br.id", "conventions.beneficiary_representative_id") - .leftJoin("actors as bce", "bce.id", "conventions.beneficiary_current_employer_id") - .leftJoin("partners_pe_connect as p", "p.convention_id", "conventions.id") - .leftJoin("view_appellations_dto as vad", "vad.appellation_code", "conventions.immersion_appellation") - .leftJoin("agencies", "agencies.id", "conventions.agency_id") +// Common type for the query builder with proper return type +type ConventionQueryBuilder = SelectQueryBuilder< + any, + any, + { dto: ConventionDto } +>; +// Function to create the common selection part with proper return type +const createConventionSelection = < + QB extends SelectQueryBuilder, +>( + builder: QB, +): SelectQueryBuilder => { return builder.select(({ ref, ...eb }) => jsonStripNulls( jsonBuildObject({ @@ -240,6 +243,65 @@ export const createConventionQueryBuilder = (transaction: KyselyDb) => { ); }; +// Function to create the common joins for both query builders +const createCommonJoins = >( + builder: QB, +): QB => { + return builder + .innerJoin("actors as b", "b.id", "conventions.beneficiary_id") + .innerJoin( + "actors as er", + "er.id", + "conventions.establishment_representative_id", + ) + .innerJoin("actors as et", "et.id", "conventions.establishment_tutor_id") + .leftJoin( + "actors as br", + "br.id", + "conventions.beneficiary_representative_id", + ) + .leftJoin( + "actors as bce", + "bce.id", + "conventions.beneficiary_current_employer_id", + ) + .leftJoin("partners_pe_connect as p", "p.convention_id", "conventions.id") + .leftJoin( + "view_appellations_dto as vad", + "vad.appellation_code", + "conventions.immersion_appellation", + ) + .leftJoin("agencies", "agencies.id", "conventions.agency_id") as QB; +}; + +export const createConventionQueryBuilder = ( + transaction: KyselyDb, +): ConventionQueryBuilder => { + // biome-ignore format: reads better without formatting + const builder = transaction + .selectFrom("conventions"); + + const builderWithJoins = createCommonJoins(builder); + return createConventionSelection(builderWithJoins); +}; + +export const createConventionQueryBuilderForAgencyUser = ({ + transaction, + agencyUserId, +}: { + transaction: KyselyDb; + agencyUserId: UserId; +}): ConventionQueryBuilder => { + // biome-ignore format: reads better without formatting + const builder = transaction + .selectFrom("users__agencies") + .innerJoin("conventions", "users__agencies.agency_id", "conventions.agency_id") + .where("users__agencies.user_id", "=", agencyUserId); + + const builderWithJoins = createCommonJoins(builder); + return createConventionSelection(builderWithJoins); +}; + export const getConventionAgencyFieldsForAgencies = async ( transaction: KyselyDb, agencyIds: AgencyId[], diff --git a/back/src/domains/convention/ports/ConventionQueries.ts b/back/src/domains/convention/ports/ConventionQueries.ts index f4695e598b..3f0e582543 100644 --- a/back/src/domains/convention/ports/ConventionQueries.ts +++ b/back/src/domains/convention/ports/ConventionQueries.ts @@ -4,11 +4,16 @@ import { ConventionReadDto, ConventionScope, ConventionStatus, + DataWithPagination, + DateFilter, DateRange, ExtractFromExisting, FindSimilarConventionsParams, + NotEmptyArray, + PaginationQueryParams, SiretDto, TemplatedEmail, + UserId, } from "shared"; export type GetConventionsFilters = { @@ -37,6 +42,30 @@ export type GetConventionsParams = { sortBy: GetConventionsSortBy; }; +export type GetPaginatedConventionsFilters = { + actorEmailContains?: string; + establishmentNameContains?: string; + beneficiaryNameContains?: string; + statuses?: NotEmptyArray; + agencyIds?: NotEmptyArray; + agencyDepartmentCodes?: NotEmptyArray; + dateStart?: DateFilter; + dateEnd?: DateFilter; + dateSubmission?: DateFilter; +}; + +export type GetPaginatedConventionsForAgencyUserParams = { + agencyUserId: UserId; + pagination: Required; + filters?: GetPaginatedConventionsFilters; + sortBy?: GetPaginatedConventionsSortBy; +}; + +export type GetPaginatedConventionsSortBy = keyof Pick< + ConventionDto, + "dateValidation" | "dateStart" | "dateSubmission" +>; + export interface ConventionQueries { getConventionById: ( id: ConventionId, @@ -45,6 +74,10 @@ export interface ConventionQueries { params: FindSimilarConventionsParams, ): Promise; + getPaginatedConventionsForAgencyUser( + params: GetPaginatedConventionsForAgencyUserParams, + ): Promise>; + // TODO: a voir si on veut pas à terme unifier en une seule query les 3 queries si dessous getConventions(params: GetConventionsParams): Promise; getAllConventionsForThoseEndingThatDidntGoThrough: ( diff --git a/shared/src/filters.ts b/shared/src/filters.ts new file mode 100644 index 0000000000..d5c5a6d4a6 --- /dev/null +++ b/shared/src/filters.ts @@ -0,0 +1,4 @@ +export type DateFilter = { + from?: Date; + to?: Date; +}; diff --git a/shared/src/index.ts b/shared/src/index.ts index c0c060bb06..0510f5a988 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -58,6 +58,7 @@ export * from "./featureFlag/featureFlags.schema"; export * from "./federatedIdentities/federatedIdentity.dto"; export * from "./file/file.dto"; export * from "./file/file.validators"; +export * from "./filters"; export * from "./formEstablishment/FormEstablishment.dto"; export * from "./formEstablishment/FormEstablishment.schema"; export * from "./formEstablishment/FormEstablishmentDtoBuilder"; diff --git a/shared/src/pipeWithValue.ts b/shared/src/pipeWithValue.ts index 4b072886d6..1dc0b1da46 100644 --- a/shared/src/pipeWithValue.ts +++ b/shared/src/pipeWithValue.ts @@ -66,6 +66,51 @@ export function pipeWithValue( hi: (h: H) => I, ): I; +// biome-ignore format: better readability without formatting +export function pipeWithValue( + a: A, + ab: (a: A) => B, + bc: (b: B) => C, + cd: (c: C) => D, + de: (d: D) => E, + ef: (e: E) => F, + fg: (f: F) => G, + gh: (g: G) => H, + hi: (h: H) => I, + ij: (i: I) => J, +): J; + +// biome-ignore format: better readability without formatting +export function pipeWithValue( + a: A, + ab: (a: A) => B, + bc: (b: B) => C, + cd: (c: C) => D, + de: (d: D) => E, + ef: (e: E) => F, + fg: (f: F) => G, + gh: (g: G) => H, + hi: (h: H) => I, + ij: (i: I) => J, + jk: (j: J) => K, +): K; + +// biome-ignore format: better readability without formatting +export function pipeWithValue( + a: A, + ab: (a: A) => B, + bc: (b: B) => C, + cd: (c: C) => D, + de: (d: D) => E, + ef: (e: E) => F, + fg: (f: F) => G, + gh: (g: G) => H, + hi: (h: H) => I, + ij: (i: I) => J, + jk: (j: J) => K, + kl?: (k: K) => L, +): L; + // biome-ignore format: better readability without formatting export function pipeWithValue( a: unknown, @@ -77,6 +122,9 @@ export function pipeWithValue( fg?: AnyFunction, gh?: AnyFunction, hi?: AnyFunction, + ij?: AnyFunction, + jk?: AnyFunction, + kl?: AnyFunction, ): unknown { // biome-ignore lint/style/noArguments: switch (arguments.length) { @@ -98,6 +146,12 @@ export function pipeWithValue( return gh?.(fg?.(ef?.(de?.(cd?.(bc?.(ab?.(a))))))); case 9: return hi?.(gh?.(fg?.(ef?.(de?.(cd?.(bc?.(ab?.(a)))))))); + case 10: + return ij?.(hi?.(gh?.(fg?.(ef?.(de?.(cd?.(bc?.(ab?.(a))))))))); + case 11: + return jk?.(ij?.(hi?.(gh?.(fg?.(ef?.(de?.(cd?.(bc?.(ab?.(a)))))))))); + case 12: + return kl?.(jk?.(ij?.(hi?.(gh?.(fg?.(ef?.(de?.(cd?.(bc?.(ab?.(a))))))))))); default: throw Error( "Cannot handle so many arguments, check : https://github.com/gcanti/fp-ts/blob/master/src/function.ts",