From bad0b73e35bbc20a295a39e0cf078eed34bbf83d Mon Sep 17 00:00:00 2001 From: Philipp Kleybolte Date: Fri, 11 Oct 2024 11:29:09 +0200 Subject: [PATCH] add sorting to find-organizations endpoint --- .../api/find-organisation-query.param.ts | 21 +- .../api/organisation.controller.spec.ts | 78 +++++- .../api/organisation.controller.ts | 11 +- .../organisation/domain/organisation.enums.ts | 5 + ...rganisation.repository.integration-spec.ts | 245 ++++++++++++++---- .../persistence/organisation.repository.ts | 28 +- src/shared/domain/order.enums.ts | 4 + 7 files changed, 340 insertions(+), 52 deletions(-) create mode 100644 src/shared/domain/order.enums.ts diff --git a/src/modules/organisation/api/find-organisation-query.param.ts b/src/modules/organisation/api/find-organisation-query.param.ts index 2fbbaf81e..ef47c5b69 100644 --- a/src/modules/organisation/api/find-organisation-query.param.ts +++ b/src/modules/organisation/api/find-organisation-query.param.ts @@ -2,9 +2,10 @@ import { AutoMap } from '@automapper/classes'; import { PagedQueryParams } from '../../../shared/paging/index.js'; import { ArrayUnique, IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; -import { OrganisationsTyp, OrganisationsTypName } from '../domain/organisation.enums.js'; +import { OrganisationSortField, OrganisationsTyp, OrganisationsTypName } from '../domain/organisation.enums.js'; import { RollenSystemRecht, RollenSystemRechtTypName } from '../../rolle/domain/rolle.enums.js'; import { TransformToArray } from '../../../shared/util/array-transform.validator.js'; +import { SortingOrder } from '../../../shared/domain/order.enums.js'; export class FindOrganisationQueryParams extends PagedQueryParams { @AutoMap() @@ -99,4 +100,22 @@ export class FindOrganisationQueryParams extends PagedQueryParams { 'Liefert Organisationen mit den angegebenen IDs, selbst wenn andere Filterkriterien nicht zutreffen (ODER-verknüpft mit anderen Kriterien).', }) public readonly organisationIds?: string[]; + + @IsOptional() + @ApiProperty({ + required: false, + nullable: true, + isArray: true, + description: 'Sortierung der Organisationen.', + }) + public readonly sortField?: OrganisationSortField; + + @IsOptional() + @ApiProperty({ + required: false, + nullable: true, + isArray: true, + description: 'Sortierung der Organisationen.', + }) + public readonly sortOrder?: SortingOrder; } diff --git a/src/modules/organisation/api/organisation.controller.spec.ts b/src/modules/organisation/api/organisation.controller.spec.ts index 59815bb02..f84d28d07 100644 --- a/src/modules/organisation/api/organisation.controller.spec.ts +++ b/src/modules/organisation/api/organisation.controller.spec.ts @@ -5,7 +5,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { DoFactory, ConfigTestModule } from '../../../../test/utils/index.js'; import { Paged } from '../../../shared/paging/paged.js'; -import { OrganisationsTyp, Traegerschaft } from '../domain/organisation.enums.js'; +import { OrganisationSortField, OrganisationsTyp, Traegerschaft } from '../domain/organisation.enums.js'; import { CreateOrganisationBodyParams } from './create-organisation.body.params.js'; import { FindOrganisationQueryParams } from './find-organisation-query.param.js'; import { OrganisationByIdParams } from './organisation-by-id.params.js'; @@ -30,6 +30,7 @@ import { OrganisationByNameQueryParams } from './organisation-by-name.query.js'; import { DBiamPersonenkontextRepo } from '../../personenkontext/persistence/dbiam-personenkontext.repo.js'; import { ParentOrganisationenResponse } from './organisation.parents.response.js'; import { ParentOrganisationsByIdsBodyParams } from './parent-organisations-by-ids.body.params.js'; +import { SortingOrder } from '../../../shared/domain/order.enums.js'; function getFakeParamsAndBody(): [OrganisationByIdParams, OrganisationByIdBodyParams] { const params: OrganisationByIdParams = { @@ -317,6 +318,81 @@ describe('OrganisationController', () => { expect(result.items.length).toEqual(3); }); + + it('should find all organizations and include sorting params', async () => { + const organisationIds: string[] = [faker.string.uuid(), faker.string.uuid()]; + + const queryParams: FindOrganisationQueryParams = { + typ: OrganisationsTyp.SONSTIGE, + searchString: faker.lorem.word(), + systemrechte: [], + administriertVon: [faker.string.uuid(), faker.string.uuid()], + // Assuming you have a field for organisationIds in your query params + organisationIds: organisationIds, + sortField: OrganisationSortField.NAME, + sortOrder: SortingOrder.ASC, + }; + + const selectedOrganisationMap: Map> = new Map( + organisationIds.map((id: string) => [ + id, + DoFactory.createOrganisationAggregate(true, { + id: id, + createdAt: faker.date.recent(), + updatedAt: faker.date.recent(), + administriertVon: faker.string.uuid(), + zugehoerigZu: faker.string.uuid(), + kennung: faker.lorem.word(), + name: faker.lorem.word(), + namensergaenzung: faker.lorem.word(), + kuerzel: faker.lorem.word(), + typ: OrganisationsTyp.SCHULE, + traegerschaft: Traegerschaft.LAND, + }), + ]), + ); + + const mockedRepoResponse: Counted> = [ + [ + DoFactory.createOrganisationAggregate(true, { + id: faker.string.uuid(), + createdAt: faker.date.recent(), + updatedAt: faker.date.recent(), + administriertVon: faker.string.uuid(), + zugehoerigZu: faker.string.uuid(), + kennung: faker.lorem.word(), + name: faker.lorem.word(), + namensergaenzung: faker.lorem.word(), + kuerzel: faker.lorem.word(), + typ: OrganisationsTyp.SCHULE, + traegerschaft: Traegerschaft.LAND, + }), + ...selectedOrganisationMap.values(), + ], + selectedOrganisationMap.size + 1, + ]; + + const permissionsMock: DeepMocked = createMock(); + + organisationRepositoryMock.findAuthorized.mockResolvedValue(mockedRepoResponse); + + const result: Paged = await organisationController.findOrganizations( + queryParams, + permissionsMock, + ); + + expect(organisationRepositoryMock.findAuthorized).toHaveBeenCalledTimes(1); + expect(organisationRepositoryMock.findAuthorized).toHaveBeenCalledWith( + permissionsMock, + queryParams.systemrechte, + { + ...queryParams, + sort: [{ field: OrganisationSortField.NAME, order: SortingOrder.ASC }], + }, + ); + + expect(result.items.length).toEqual(3); + }); }); }); diff --git a/src/modules/organisation/api/organisation.controller.ts b/src/modules/organisation/api/organisation.controller.ts index 27167d663..4b8a2ae35 100644 --- a/src/modules/organisation/api/organisation.controller.ts +++ b/src/modules/organisation/api/organisation.controller.ts @@ -36,7 +36,7 @@ import { FindOrganisationQueryParams } from './find-organisation-query.param.js' import { OrganisationByIdParams } from './organisation-by-id.params.js'; import { UpdateOrganisationBodyParams } from './update-organisation.body.params.js'; import { OrganisationByIdBodyParams } from './organisation-by-id.body.params.js'; -import { OrganisationRepository } from '../persistence/organisation.repository.js'; +import { OrganisationRepository, OrganisationSeachOptions } from '../persistence/organisation.repository.js'; import { Organisation } from '../domain/organisation.js'; import { OrganisationResponse } from './organisation.response.js'; import { Permissions } from '../../authentication/api/permissions.decorator.js'; @@ -258,10 +258,17 @@ export class OrganisationController { @Query() queryParams: FindOrganisationQueryParams, @Permissions() permissions: PersonPermissions, ): Promise> { + let organisationSeachOptions: OrganisationSeachOptions = queryParams; + if (queryParams.sortField && queryParams.sortOrder) { + organisationSeachOptions = { + ...queryParams, + sort: [{ field: queryParams.sortField, order: queryParams.sortOrder }], + }; + } const [organisations, total]: Counted> = await this.organisationRepository.findAuthorized( permissions, queryParams.systemrechte, - queryParams, + organisationSeachOptions, ); const organisationResponses: OrganisationResponse[] = organisations.map((organisation: Organisation) => { diff --git a/src/modules/organisation/domain/organisation.enums.ts b/src/modules/organisation/domain/organisation.enums.ts index 552452813..68bb052f0 100644 --- a/src/modules/organisation/domain/organisation.enums.ts +++ b/src/modules/organisation/domain/organisation.enums.ts @@ -25,3 +25,8 @@ export enum RootDirectChildrenType { ERSATZ = 'ERSATZ', OEFFENTLICH = 'OEFFENTLICH', } + +export enum OrganisationSortField { + KENNUNG = 'kennung', + NAME = 'name', +} diff --git a/src/modules/organisation/persistence/organisation.repository.integration-spec.ts b/src/modules/organisation/persistence/organisation.repository.integration-spec.ts index 83552a455..a9b2b7d57 100644 --- a/src/modules/organisation/persistence/organisation.repository.integration-spec.ts +++ b/src/modules/organisation/persistence/organisation.repository.integration-spec.ts @@ -13,7 +13,7 @@ import { OrganisationPersistenceMapperProfile } from './organisation-persistence import { OrganisationEntity } from './organisation.entity.js'; import { Organisation } from '../domain/organisation.js'; import { OrganisationScope } from './organisation.scope.js'; -import { RootDirectChildrenType, OrganisationsTyp } from '../domain/organisation.enums.js'; +import { RootDirectChildrenType, OrganisationsTyp, OrganisationSortField } from '../domain/organisation.enums.js'; import { ScopeOperator } from '../../../shared/persistence/index.js'; import { ConfigService } from '@nestjs/config'; import { ServerConfig } from '../../../shared/config/server.config.js'; @@ -26,6 +26,7 @@ import { EntityCouldNotBeUpdated } from '../../../shared/error/entity-could-not- import { OrganisationSpecificationError } from '../specification/error/organisation-specification.error.js'; import { PersonPermissions } from '../../authentication/domain/person-permissions.js'; import { RollenSystemRecht } from '../../rolle/domain/rolle.enums.js'; +import { SortingOrder } from '../../../shared/domain/order.enums.js'; describe('OrganisationRepository', () => { let module: TestingModule; @@ -1025,6 +1026,158 @@ describe('OrganisationRepository', () => { expect(result[1]).toBe(5); }); + it('should order result by kennung and name by default', async () => { + const orgas: OrganisationEntity[] = []; + const orga1: Organisation | DomainError = Organisation.createNew( + sut.ROOT_ORGANISATION_ID, + sut.ROOT_ORGANISATION_ID, + '7777777', + faker.company.name(), + ); + if (orga1 instanceof DomainError) { + return; + } + const mappedOrga1: OrganisationEntity = em.create(OrganisationEntity, mapAggregateToData(orga1)); + await em.persistAndFlush(mappedOrga1); + orgas.push(mappedOrga1); + + const orga2: Organisation | DomainError = Organisation.createNew( + sut.ROOT_ORGANISATION_ID, + sut.ROOT_ORGANISATION_ID, + undefined, + 'eeeeeee', + ); + if (orga2 instanceof DomainError) { + return; + } + const mappedOrga2: OrganisationEntity = em.create(OrganisationEntity, mapAggregateToData(orga2)); + await em.persistAndFlush(mappedOrga2); + orgas.push(mappedOrga2); + + const orga3: Organisation | DomainError = Organisation.createNew( + sut.ROOT_ORGANISATION_ID, + sut.ROOT_ORGANISATION_ID, + undefined, + 'aaaaaaa', + ); + if (orga3 instanceof DomainError) { + return; + } + const mappedOrga3: OrganisationEntity = em.create(OrganisationEntity, mapAggregateToData(orga3)); + await em.persistAndFlush(mappedOrga3); + orgas.push(mappedOrga3); + + const orga4: Organisation | DomainError = Organisation.createNew( + sut.ROOT_ORGANISATION_ID, + sut.ROOT_ORGANISATION_ID, + '3333333', + 'aaaaaaa', + ); + if (orga4 instanceof DomainError) { + return; + } + const mappedOrga4: OrganisationEntity = em.create(OrganisationEntity, mapAggregateToData(orga4)); + await em.persistAndFlush(mappedOrga4); + orgas.push(mappedOrga4); + + const personPermissions: DeepMocked = createMock(); + personPermissions.getOrgIdsWithSystemrecht.mockResolvedValue({ all: true }); + + const result: Counted> = await sut.findAuthorized( + personPermissions, + [RollenSystemRecht.SCHULEN_VERWALTEN], + {}, + ); + + expect(result[1]).toBe(4); + expect(result[0][0]?.name).toBe(orga3.name); + expect(result[0][1]?.name).toBe(orga2.name); + expect(result[0][2]?.kennung).toBe(orga4.kennung); + expect(result[0][3]?.kennung).toBe(orga1.kennung); + }); + + it('should order result by name if requested', async () => { + const orgas: OrganisationEntity[] = []; + const orga1: Organisation | DomainError = Organisation.createNew( + sut.ROOT_ORGANISATION_ID, + sut.ROOT_ORGANISATION_ID, + '7777777', + 'ccccccc', + ); + if (orga1 instanceof DomainError) { + return; + } + const mappedOrga1: OrganisationEntity = em.create(OrganisationEntity, mapAggregateToData(orga1)); + await em.persistAndFlush(mappedOrga1); + orgas.push(mappedOrga1); + + const orga2: Organisation | DomainError = Organisation.createNew( + sut.ROOT_ORGANISATION_ID, + sut.ROOT_ORGANISATION_ID, + undefined, + 'eeeeeee', + ); + if (orga2 instanceof DomainError) { + return; + } + const mappedOrga2: OrganisationEntity = em.create(OrganisationEntity, mapAggregateToData(orga2)); + await em.persistAndFlush(mappedOrga2); + orgas.push(mappedOrga2); + + const orga3: Organisation | DomainError = Organisation.createNew( + sut.ROOT_ORGANISATION_ID, + sut.ROOT_ORGANISATION_ID, + undefined, + 'aaaaaaa', + ); + if (orga3 instanceof DomainError) { + return; + } + const mappedOrga3: OrganisationEntity = em.create(OrganisationEntity, mapAggregateToData(orga3)); + await em.persistAndFlush(mappedOrga3); + orgas.push(mappedOrga3); + + const orga4: Organisation | DomainError = Organisation.createNew( + sut.ROOT_ORGANISATION_ID, + sut.ROOT_ORGANISATION_ID, + '3333333', + 'bbbbbbb', + ); + if (orga4 instanceof DomainError) { + return; + } + const mappedOrga4: OrganisationEntity = em.create(OrganisationEntity, mapAggregateToData(orga4)); + await em.persistAndFlush(mappedOrga4); + orgas.push(mappedOrga4); + + const personPermissions: DeepMocked = createMock(); + personPermissions.getOrgIdsWithSystemrecht.mockResolvedValue({ all: true }); + + const result: Counted> = await sut.findAuthorized( + personPermissions, + [RollenSystemRecht.SCHULEN_VERWALTEN], + { sort: [{ field: OrganisationSortField.NAME, order: SortingOrder.ASC }] }, + ); + + expect(result[1]).toBe(4); + expect(result[0][0]?.name).toBe(orga3.name); + expect(result[0][1]?.name).toBe(orga4.name); + expect(result[0][2]?.name).toBe(orga1.name); + expect(result[0][3]?.name).toBe(orga2.name); + + const result2: Counted> = await sut.findAuthorized( + personPermissions, + [RollenSystemRecht.SCHULEN_VERWALTEN], + { sort: [{ field: OrganisationSortField.NAME, order: SortingOrder.DESC }] }, + ); + + expect(result2[1]).toBe(4); + expect(result2[0][0]?.name).toBe(orga2.name); + expect(result2[0][1]?.name).toBe(orga1.name); + expect(result2[0][2]?.name).toBe(orga4.name); + expect(result2[0][3]?.name).toBe(orga3.name); + }); + it('should return no organisations if not authorized', async () => { const orgas: OrganisationEntity[] = []; for (let i: number = 0; i < 5; i++) { @@ -1529,58 +1682,60 @@ describe('OrganisationRepository', () => { }); }); - it('should only return organisations with the permitted IDs', async () => { - const organisations: OrganisationEntity[] = []; + describe('findByNameOrKennungAndExcludeByOrganisationType', () => { + it('should only return organisations with the permitted IDs', async () => { + const organisations: OrganisationEntity[] = []; - // Create 5 organisations - for (let i: number = 0; i < 5; i++) { - const orga: Organisation | DomainError = Organisation.createNew( - sut.ROOT_ORGANISATION_ID, - sut.ROOT_ORGANISATION_ID, - faker.string.numeric(6), - faker.company.name(), - OrganisationsTyp.SCHULE, - ); - if (orga instanceof DomainError) { - fail('Could not create Organisation'); + // Create 5 organisations + for (let i: number = 0; i < 5; i++) { + const orga: Organisation | DomainError = Organisation.createNew( + sut.ROOT_ORGANISATION_ID, + sut.ROOT_ORGANISATION_ID, + faker.string.numeric(6), + faker.company.name(), + OrganisationsTyp.SCHULE, + ); + if (orga instanceof DomainError) { + fail('Could not create Organisation'); + } + const mappedOrga: OrganisationEntity = em.create(OrganisationEntity, mapAggregateToData(orga)); + await em.persistAndFlush(mappedOrga); + organisations.push(mappedOrga); } - const mappedOrga: OrganisationEntity = em.create(OrganisationEntity, mapAggregateToData(orga)); - await em.persistAndFlush(mappedOrga); - organisations.push(mappedOrga); - } - organisations.filter((orga: OrganisationEntity): orga is OrganisationEntity => orga !== undefined); + organisations.filter((orga: OrganisationEntity): orga is OrganisationEntity => orga !== undefined); - // Only permit the first and third organisation - const permittedOrgaIds: string[] = [organisations[0]?.id, organisations[2]?.id].filter( - (id: string | undefined): id is string => !!id, - ); + // Only permit the first and third organisation + const permittedOrgaIds: string[] = [organisations[0]?.id, organisations[2]?.id].filter( + (id: string | undefined): id is string => !!id, + ); - const finalOrgas: Organisation[] = []; + const finalOrgas: Organisation[] = []; - for (const orga of organisations) { - const orgaAggregate: Organisation = mapEntityToAggregate(orga); - finalOrgas.push(orgaAggregate); - } + for (const orga of organisations) { + const orgaAggregate: Organisation = mapEntityToAggregate(orga); + finalOrgas.push(orgaAggregate); + } - jest.spyOn(sut, 'findBy').mockResolvedValueOnce([ - finalOrgas.filter((orga: Organisation) => permittedOrgaIds.includes(orga.id)), - 2, - ]); + jest.spyOn(sut, 'findBy').mockResolvedValueOnce([ + finalOrgas.filter((orga: Organisation) => permittedOrgaIds.includes(orga.id)), + 2, + ]); - if (permittedOrgaIds && permittedOrgaIds.length > 0) { - // Call the method with permitted organisation IDs - const result: Organisation[] = await sut.findByNameOrKennungAndExcludeByOrganisationType( - OrganisationsTyp.KLASSE, // Exclude some type, not relevant here - undefined, - permittedOrgaIds, - 25, - ); + if (permittedOrgaIds && permittedOrgaIds.length > 0) { + // Call the method with permitted organisation IDs + const result: Organisation[] = await sut.findByNameOrKennungAndExcludeByOrganisationType( + OrganisationsTyp.KLASSE, // Exclude some type, not relevant here + undefined, + permittedOrgaIds, + 25, + ); - // Verify the result contains only the permitted organisations - expect(result.length).toBe(2); - expect(result.some((org: Organisation) => org.id === organisations[0]?.id)).toBeTruthy(); - expect(result.some((org: Organisation) => org.id === organisations[2]?.id)).toBeTruthy(); - } + // Verify the result contains only the permitted organisations + expect(result.length).toBe(2); + expect(result.some((org: Organisation) => org.id === organisations[0]?.id)).toBeTruthy(); + expect(result.some((org: Organisation) => org.id === organisations[2]?.id)).toBeTruthy(); + } + }); }); }); diff --git a/src/modules/organisation/persistence/organisation.repository.ts b/src/modules/organisation/persistence/organisation.repository.ts index 43092c2e0..f7c2d7751 100644 --- a/src/modules/organisation/persistence/organisation.repository.ts +++ b/src/modules/organisation/persistence/organisation.repository.ts @@ -7,6 +7,8 @@ import { SelectQueryBuilder, EntityDictionary, QueryOrder, + QBQueryOrderMap, + EntityKey, } from '@mikro-orm/postgresql'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; @@ -15,7 +17,7 @@ import { OrganisationID } from '../../../shared/types/aggregate-ids.types.js'; import { Organisation } from '../domain/organisation.js'; import { OrganisationEntity } from './organisation.entity.js'; import { OrganisationScope } from './organisation.scope.js'; -import { OrganisationsTyp, RootDirectChildrenType } from '../domain/organisation.enums.js'; +import { OrganisationSortField, OrganisationsTyp, RootDirectChildrenType } from '../domain/organisation.enums.js'; import { SchuleCreatedEvent } from '../../../shared/events/schule-created.event.js'; import { EventService } from '../../../core/eventbus/services/event.service.js'; import { ScopeOperator } from '../../../shared/persistence/scope.enums.js'; @@ -28,6 +30,7 @@ import { KlasseUpdatedEvent } from '../../../shared/events/klasse-updated.event. import { KlasseCreatedEvent } from '../../../shared/events/klasse-created.event.js'; import { PermittedOrgas, PersonPermissions } from '../../authentication/domain/person-permissions.js'; import { RollenSystemRecht } from '../../rolle/domain/rolle.enums.js'; +import { SortingOrder } from '../../../shared/domain/order.enums.js'; export function mapAggregateToData(organisation: Organisation): RequiredEntityData { return { @@ -61,6 +64,8 @@ export function mapEntityToAggregate(entity: OrganisationEntity): Organisation[] = [ + { kennung: QueryOrder.ASC_NULLS_FIRST }, + { name: QueryOrder.ASC_NULLS_FIRST }, + ]; + if (searchOptions.sort && searchOptions.sort.length > 0) { + orderBy = []; + searchOptions.sort.forEach((sortFieldOrder: OrganisationSortFieldOrder) => { + const order: QueryOrder = + sortFieldOrder.order === SortingOrder.ASC + ? QueryOrder.ASC_NULLS_FIRST + : QueryOrder.DESC_NULLS_FIRST; + const field: EntityKey = sortFieldOrder.field; + orderBy.push({ [field]: order }); + }); + } + let entitiesForIds: OrganisationEntity[] = []; const qb: QueryBuilder = this.em.createQueryBuilder(OrganisationEntity); @@ -314,7 +336,7 @@ export class OrganisationRepository { const queryForIds: SelectQueryBuilder = qb .select('*') .where({ id: { $in: organisationIds } }) - .orderBy([{ kennung: QueryOrder.ASC_NULLS_FIRST }, { name: QueryOrder.ASC_NULLS_FIRST }]) + .orderBy(orderBy) .limit(searchOptions.limit); entitiesForIds = (await queryForIds.getResultAndCount())[0]; } @@ -356,7 +378,7 @@ export class OrganisationRepository { .select('*') .where(whereClause) .offset(searchOptions.offset) - .orderBy([{ kennung: QueryOrder.ASC_NULLS_FIRST }, { name: QueryOrder.ASC_NULLS_FIRST }]) + .orderBy(orderBy) .limit(searchOptions.limit); const [entities, total]: Counted = await query.getResultAndCount(); diff --git a/src/shared/domain/order.enums.ts b/src/shared/domain/order.enums.ts new file mode 100644 index 000000000..9c9b4aa22 --- /dev/null +++ b/src/shared/domain/order.enums.ts @@ -0,0 +1,4 @@ +export enum SortingOrder { + ASC = 'asc', + DESC = 'desc', +}