Skip to content

Commit

Permalink
Duplicates backend & integration tests
Browse files Browse the repository at this point in the history
  • Loading branch information
Ruben committed Feb 20, 2025
1 parent 13fc275 commit a4bb9de
Show file tree
Hide file tree
Showing 20 changed files with 930 additions and 21 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { ApiProperty } from '@nestjs/swagger';

export class DuplicateReponseDto {
@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 isInScope: 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[];
}
44 changes: 44 additions & 0 deletions services/121-service/src/registration/registration-view.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,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 +83,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 Down Expand Up @@ -153,6 +194,9 @@ export class RegistrationViewEntity {
@ViewColumn()
public scope: string;

@ViewColumn()
public duplicateStatus: DuplicateStatus;

@OneToMany(
() => RegistrationAttributeDataEntity,
(registrationData) => registrationData.registration,
Expand Down
26 changes: 26 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 { DuplicateReponseDto } from '@121-service/src/registration/dto/duplicate-response.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,31 @@ export class RegistrationsController {
);
}

@ApiTags('programs/registrations')
@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, it is only possible to request duplicates for a registration that the logged in user has access to. For the duplicate registrations that are returned the "name" property is only visisble for registrations in the user's scope.`,
type: DuplicateReponseDto,
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<DuplicateReponseDto[]> {
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 { DuplicateReponseDto } from '@121-service/src/registration/dto/duplicate-response.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<DuplicateReponseDto[]> {
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 duplicates using the pagination functionality
// This is done to avoid duplicating the complex logic of retrieving full names, which is already implemented in the pagination service
// TODO: In the future, this logic should be refactored to reside 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,
isInScope: registration !== undefined,
};
});
}

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

0 comments on commit a4bb9de

Please sign in to comment.