Skip to content

Commit

Permalink
Duplicates backend & integration tests
Browse files Browse the repository at this point in the history
  • Loading branch information
Ruben authored and aberonni committed Feb 18, 2025
1 parent d4389d1 commit e178753
Show file tree
Hide file tree
Showing 20 changed files with 809 additions and 23 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class DuplicateStatus1739551473017 implements MigrationInterface {
name = 'DuplicateStatus1739551473017';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DELETE FROM "121-service"."typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "schema" = $3`,
['VIEW', 'registration_view', '121-service'],
);
await queryRunner.query(`DROP VIEW "121-service"."registration_view"`);
await queryRunner.query(`CREATE VIEW "121-service"."registration_view" AS SELECT "registration"."id" AS "id", "registration"."created" AS "registrationCreated", "registration"."programId" AS "programId", "registration"."registrationStatus" AS "status", "registration"."referenceId" AS "referenceId", "registration"."phoneNumber" AS "phoneNumber", "registration"."preferredLanguage" AS "preferredLanguage", "registration"."inclusionScore" AS "inclusionScore", "registration"."paymentAmountMultiplier" AS "paymentAmountMultiplier", "registration"."maxPayments" AS "maxPayments", "registration"."paymentCount" AS "paymentCount", "registration"."scope" AS "scope", "fspconfig"."label" AS "programFinancialServiceProviderConfigurationLabel", CAST(CONCAT('PA #',registration."registrationProgramId") as VARCHAR) AS "personAffectedSequence", registration."registrationProgramId" AS "registrationProgramId", TO_CHAR("registration"."created",'yyyy-mm-dd') AS "registrationCreatedDate", fspconfig."name" AS "programFinancialServiceProviderConfigurationName", fspconfig."id" AS "programFinancialServiceProviderConfigurationId", fspconfig."financialServiceProviderName" AS "financialServiceProviderName", "registration"."maxPayments" - "registration"."paymentCount" AS "paymentCountRemaining", COALESCE("message"."type" || ': ' || "message"."status",'no messages yet') AS "lastMessageStatus",
(CASE
WHEN dup."registrationId" IS NOT NULL THEN 'duplicate'
ELSE 'unique'
END)
AS "duplicateStatus" FROM "121-service"."registration" "registration" LEFT JOIN "121-service"."program_financial_service_provider_configuration" "fspconfig" ON "fspconfig"."id"="registration"."programFinancialServiceProviderConfigurationId" LEFT JOIN "121-service"."latest_message" "latestMessage" ON "latestMessage"."registrationId"="registration"."id" LEFT JOIN "121-service"."twilio_message" "message" ON "message"."id"="latestMessage"."messageId" LEFT JOIN (SELECT distinct d1."registrationId" FROM "121-service"."registration_attribute_data" "d1" INNER JOIN "121-service"."registration_attribute_data" "d2" ON d1."programRegistrationAttributeId" = d2."programRegistrationAttributeId" AND "d1"."value" = "d2"."value" AND d1."registrationId" != d2."registrationId" INNER JOIN "121-service"."registration" "registration1" ON d1."registrationId" = "registration1"."id" AND registration1."registrationStatus" != 'declined' INNER JOIN "121-service"."registration" "registration2" ON d2."registrationId" = "registration2"."id" AND registration2."registrationStatus" != 'declined' INNER JOIN "121-service"."program_registration_attribute" "pra" ON d1."programRegistrationAttributeId" = "pra"."id" WHERE "d1"."value" != '' AND pra."duplicateCheck" = true) "dup" ON "registration"."id" = dup."registrationId" ORDER BY "registration"."registrationProgramId" ASC`);
await queryRunner.query(
`INSERT INTO "121-service"."typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES (DEFAULT, $1, DEFAULT, $2, $3, $4)`,
[
'121-service',
'VIEW',
'registration_view',
'SELECT "registration"."id" AS "id", "registration"."created" AS "registrationCreated", "registration"."programId" AS "programId", "registration"."registrationStatus" AS "status", "registration"."referenceId" AS "referenceId", "registration"."phoneNumber" AS "phoneNumber", "registration"."preferredLanguage" AS "preferredLanguage", "registration"."inclusionScore" AS "inclusionScore", "registration"."paymentAmountMultiplier" AS "paymentAmountMultiplier", "registration"."maxPayments" AS "maxPayments", "registration"."paymentCount" AS "paymentCount", "registration"."scope" AS "scope", "fspconfig"."label" AS "programFinancialServiceProviderConfigurationLabel", CAST(CONCAT(\'PA #\',registration."registrationProgramId") as VARCHAR) AS "personAffectedSequence", registration."registrationProgramId" AS "registrationProgramId", TO_CHAR("registration"."created",\'yyyy-mm-dd\') AS "registrationCreatedDate", fspconfig."name" AS "programFinancialServiceProviderConfigurationName", fspconfig."id" AS "programFinancialServiceProviderConfigurationId", fspconfig."financialServiceProviderName" AS "financialServiceProviderName", "registration"."maxPayments" - "registration"."paymentCount" AS "paymentCountRemaining", COALESCE("message"."type" || \': \' || "message"."status",\'no messages yet\') AS "lastMessageStatus", \n (CASE\n WHEN dup."registrationId" IS NOT NULL THEN \'duplicate\'\n ELSE \'unique\'\n END)\n AS "duplicateStatus" FROM "121-service"."registration" "registration" LEFT JOIN "121-service"."program_financial_service_provider_configuration" "fspconfig" ON "fspconfig"."id"="registration"."programFinancialServiceProviderConfigurationId" LEFT JOIN "121-service"."latest_message" "latestMessage" ON "latestMessage"."registrationId"="registration"."id" LEFT JOIN "121-service"."twilio_message" "message" ON "message"."id"="latestMessage"."messageId" LEFT JOIN (SELECT distinct d1."registrationId" FROM "121-service"."registration_attribute_data" "d1" INNER JOIN "121-service"."registration_attribute_data" "d2" ON d1."programRegistrationAttributeId" = d2."programRegistrationAttributeId" AND "d1"."value" = "d2"."value" AND d1."registrationId" != d2."registrationId" INNER JOIN "121-service"."registration" "registration1" ON d1."registrationId" = "registration1"."id" AND registration1."registrationStatus" != \'declined\' INNER JOIN "121-service"."registration" "registration2" ON d2."registrationId" = "registration2"."id" AND registration2."registrationStatus" != \'declined\' INNER JOIN "121-service"."program_registration_attribute" "pra" ON d1."programRegistrationAttributeId" = "pra"."id" WHERE "d1"."value" != \'\' AND pra."duplicateCheck" = true) "dup" ON "registration"."id" = dup."registrationId" ORDER BY "registration"."registrationProgramId" ASC',
],
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DELETE FROM "121-service"."typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "schema" = $3`,
['VIEW', 'registration_view', '121-service'],
);
await queryRunner.query(`DROP VIEW "121-service"."registration_view"`);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const basePaginateConfigRegistrationView: PaginateConfig<RegistrationViewEntity>
'paymentCount',
'paymentCountRemaining',
'lastMessageStatus',
'duplicateStatus',
'data.value',
],
filterableColumns: {
Expand All @@ -59,6 +60,7 @@ const basePaginateConfigRegistrationView: PaginateConfig<RegistrationViewEntity>
paymentCountRemaining: AllowedFilterOperatorsNumber,
personAffectedSequence: AllowedFilterOperatorsString,
lastMessageStatus: AllowedFilterOperatorsString,
duplicateStatus: AllowedFilterOperatorsString,
},
};

Expand Down
23 changes: 23 additions & 0 deletions services/121-service/src/registration/dto/duplicate.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { ApiProperty } from '@nestjs/swagger';

// TODO: Discuss should this be name DuplicateReponseDto? (as in the guidelines) And that the api returns DuplicateReponseDto[]?
// or should we have DuplicateDto and DuplicateResponseDto? With the latter being the response from the api which equals DuplicateDto[]
export class DuplicateDto {
@ApiProperty({ example: 'Juan Garcia' })
public readonly name?: string;

@ApiProperty({ example: 1 })
public readonly registrationId: number;

@ApiProperty({ example: 1 })
public readonly registrationProgramId: number;

@ApiProperty({ example: 'zeeland' })
public readonly scope: string;

@ApiProperty({ example: ['phoneNumber'] })
public readonly attributeNames: string[];

@ApiProperty({ example: true })
public readonly isDuplicateAccessibleWithinScope: boolean;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// The reason we have this enum instead of a boolean is because we expect to have more statuses in the future
export enum DuplicateStatus {
duplicate = 'duplicate',
unique = 'unique',
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export interface GetDuplicatesResult {
registrationId: number;
referenceId: string;
registrationProgramId: number;
scope: string;
attributeNames: string[];
}
49 changes: 47 additions & 2 deletions services/121-service/src/registration/registration-view.entity.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
/* eslint-disable custom-rules/typeorm-cascade-ondelete*/ // as cascade delete is not applicable for views
import {
Column,
DataSource,
Expand All @@ -13,6 +12,7 @@ import {
import { FinancialServiceProviders } from '@121-service/src/financial-service-providers/enum/financial-service-provider-name.enum';
import { LatestTransactionEntity } from '@121-service/src/payments/transactions/latest-transaction.entity';
import { ProgramEntity } from '@121-service/src/programs/program.entity';
import { DuplicateStatus } from '@121-service/src/registration/enum/duplicate-status.enum';
import { RegistrationStatusEnum } from '@121-service/src/registration/enum/registration-status.enum';
import { RegistrationEntity } from '@121-service/src/registration/registration.entity';
import { RegistrationAttributeDataEntity } from '@121-service/src/registration/registration-attribute-data.entity';
Expand Down Expand Up @@ -82,6 +82,46 @@ import { LocalizedString } from '@121-service/src/shared/types/localized-string.
.addSelect(
`COALESCE(message.type || ': ' || message.status,'no messages yet')`,
'lastMessageStatus',
)

.addSelect(
`
(CASE
WHEN dup."registrationId" IS NOT NULL THEN 'duplicate'
ELSE 'unique'
END)
`,
'duplicateStatus',
)
.leftJoin(
(qb) =>
qb
.select('distinct d1."registrationId"')
.from('registration_attribute_data', 'd1')
.innerJoin(
'registration_attribute_data',
'd2',
'd1."programRegistrationAttributeId" = d2."programRegistrationAttributeId" AND d1.value = d2.value AND d1."registrationId" != d2."registrationId"',
)
.innerJoin(
'registration',
'registration1',
`d1."registrationId" = registration1.id AND registration1."registrationStatus" != '${RegistrationStatusEnum.declined}'`,
)
.innerJoin(
'registration',
'registration2',
`d2."registrationId" = registration2.id AND registration2."registrationStatus" != '${RegistrationStatusEnum.declined}'`,
)
.innerJoin(
'program_registration_attribute',
'pra',
'd1."programRegistrationAttributeId" = pra.id',
)
.andWhere("d1.value != ''")
.andWhere('pra."duplicateCheck" = true'),
'dup',
'registration.id = dup."registrationId"',
),
})
export class RegistrationViewEntity {
Expand All @@ -92,7 +132,9 @@ export class RegistrationViewEntity {
@ViewColumn()
public status: RegistrationStatusEnum;

@ManyToOne((_type) => ProgramEntity, (program) => program.registrations)
@ManyToOne((_type) => ProgramEntity, (program) => program.registrations, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'programId' })
public program: ProgramEntity;
@Column()
Expand Down Expand Up @@ -153,6 +195,9 @@ export class RegistrationViewEntity {
@ViewColumn()
public scope: string;

@ViewColumn()
public duplicateStatus: DuplicateStatus;

@OneToMany(
() => RegistrationAttributeDataEntity,
(registrationData) => registrationData.registration,
Expand Down
27 changes: 27 additions & 0 deletions services/121-service/src/registration/registrations.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
ImportResult,
} from '@121-service/src/registration/dto/bulk-import.dto';
import { DeleteRegistrationsDto } from '@121-service/src/registration/dto/delete-registrations.dto';
import { DuplicateDto } from '@121-service/src/registration/dto/duplicate.dto';
import { FindAllRegistrationsResultDto } from '@121-service/src/registration/dto/find-all-registrations-result.dto';
import { MappedPaginatedRegistrationDto } from '@121-service/src/registration/dto/mapped-paginated-registration.dto';
import { MessageHistoryDto } from '@121-service/src/registration/dto/message-history.dto';
Expand Down Expand Up @@ -658,6 +659,32 @@ export class RegistrationsController {
);
}

@ApiTags('programs/registration')
@AuthenticatedUser({ permissions: [PermissionEnum.RegistrationPersonalREAD] })
@ApiOperation({
summary: '[SCOPED] Gets duplicate registrations for a registration',
})
@ApiParam({ name: 'programId', required: true, type: 'integer' })
@ApiParam({ name: 'referenceId', required: true, type: 'string' })
@ApiResponse({
status: HttpStatus.OK,
description:
'Returns duplicate registrations for a registration. NOTE: this endpoint is scoped, depending on program configuration it only returns/modifies data the logged in user has access to.',
type: DuplicateDto,
isArray: true,
})
@Get('programs/:programId/registrations/:referenceId/duplicates')
public async getDuplicates(
@Param('referenceId') referenceId: string, // TODO: change to registrationId for now we use referenceId as else a lot of helper code needs to be duplicated to start using registrationId in these controllers
@Param('programId', ParseIntPipe)
programId: number,
): Promise<DuplicateDto[]> {
return await this.registrationsService.getDuplicates(
referenceId,
programId,
);
}

@ApiTags('programs/registrations')
@AuthenticatedUser({ permissions: [PermissionEnum.RegistrationREAD] })
@ApiOperation({ summary: '[SCOPED] Get Person Affected referenceId' })
Expand Down
44 changes: 44 additions & 0 deletions services/121-service/src/registration/registrations.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { ProgramEntity } from '@121-service/src/programs/program.entity';
import { ProgramRegistrationAttributeEntity } from '@121-service/src/programs/program-registration-attribute.entity';
import { ImportResult } from '@121-service/src/registration/dto/bulk-import.dto';
import { CreateRegistrationDto } from '@121-service/src/registration/dto/create-registration.dto';
import { DuplicateDto } from '@121-service/src/registration/dto/duplicate.dto';
import { MappedPaginatedRegistrationDto } from '@121-service/src/registration/dto/mapped-paginated-registration.dto';
import { MessageHistoryDto } from '@121-service/src/registration/dto/message-history.dto';
import { ReferenceProgramIdScopeDto } from '@121-service/src/registration/dto/registrationProgramIdScope.dto';
Expand Down Expand Up @@ -906,6 +907,49 @@ export class RegistrationsService {
return result;
}

public async getDuplicates(
referenceId: string,
programId: number,
): Promise<DuplicateDto[]> {
const registration = await this.getRegistrationOrThrow({
referenceId,
programId,
});
const duplicates = await this.registrationScopedRepository.getDuplicates({
registrationId: registration.id,
programId,
});
if (duplicates.length === 0) {
return [];
}
const referenceIds = duplicates.map((d) => d.referenceId);
// Get the full names of the duplicate using the pagination functionality
// This is done to avoid because getting fullNames because this is complex logic and in the pagination service this is already implemented
// TODO: In the future this can be refactored so this logic lives in the registration repository
const registrationViews =
await this.registrationsPaginationService.getRegistrationViewsByReferenceIds(
{
programId,
referenceIds,
},
);

// Add the name to the duplicate information together in one object
return duplicates.map((duplicate) => {
const registration = registrationViews.find(
(r) => r.id === duplicate.registrationId,
);
return {
registrationId: duplicate.registrationId,
registrationProgramId: duplicate.registrationProgramId,
attributeNames: duplicate.attributeNames,
scope: duplicate.scope,
name: registration?.name,
isDuplicateAccessibleWithinScope: registration !== undefined,
};
});
}

public async getReferenceId(
programId: number,
paId: number,
Expand Down
Loading

0 comments on commit e178753

Please sign in to comment.