diff --git a/scripts/env/environments/00-common b/scripts/env/environments/00-common index 03bdbda1827..a562a6e2b3f 100644 --- a/scripts/env/environments/00-common +++ b/scripts/env/environments/00-common @@ -5,7 +5,7 @@ REGION="${DEFAULT_REGION}" CURRENT_COLOR=$(./scripts/dynamo/get-current-color.sh "${ENV}") SOURCE_TABLE=$(./scripts/dynamo/get-source-table.sh "${ENV}") SOURCE_TABLE_VERSION="${SOURCE_TABLE//efcms-${ENV}-/}" -DB_HOST=$(./scripts/postgres/get-host.sh -h) +DB_HOST=$(./scripts/postgres/get-host.sh -h -w) # region hard-coded; all ES domains and Cognito user pools are in us-east-1 ELASTICSEARCH_ENDPOINT=$(aws es describe-elasticsearch-domain \ diff --git a/scripts/run-once-scripts/cleanup-corrupt-messages.ts b/scripts/run-once-scripts/cleanup-corrupt-messages.ts new file mode 100755 index 00000000000..fbaf9e9666a --- /dev/null +++ b/scripts/run-once-scripts/cleanup-corrupt-messages.ts @@ -0,0 +1,244 @@ +#!/usr/bin/env npx ts-node --transpile-only + +import { type ScriptConfig, parseArguments } from '../reports/reportUtils'; +import { + type ServerApplicationContext, + createApplicationContext, +} from '../../web-api/src/applicationContext'; +import { requireEnvVars } from '../../shared/admin-tools/util'; +import { connect } from '../../web-api/src/database'; +import PQueue from 'p-queue'; +import fs from 'fs'; +import { queryFull } from '../../web-api/src/persistence/dynamodbClientService'; +import { Signer } from '@aws-sdk/rds-signer'; +import path from 'path'; +import { Kysely } from 'kysely'; +import { Database } from '../../web-api/src/database-types'; +import { RawCorrespondence } from '../../shared/src/business/entities/Correspondence'; + +requireEnvVars(['REGION', 'DB_NAME', 'DB_HOST', 'DB_USER']); +const { DB_NAME, DB_HOST, DB_USER } = process.env; +const DB_PORT = 5432; + +const scriptConfig: ScriptConfig = { + parameters: { + liveRun: { + required: false, + type: 'boolean', + default: false, + long: 'live-run', + description: + 'If true, will proceed with removing the attachments from the impacted messages.', + }, + }, +}; + +type MessageFragment = { + attachments: any[] | undefined; + docketNumber: string; + messageId: string; +}; + +const getDocketEntryIdsByDocketNumbers = async ({ + applicationContext, + docketNumbers, +}: { + applicationContext: ServerApplicationContext; + docketNumbers: string[]; +}): Promise => { + console.log(`Fetching docket entries for each docket number...`); + + const priorityQueue = new PQueue({ concurrency: 50 }); + + const docketEntryIdsByDocketNumber: Record = {}; + const correspondenceIdsByDocketNumber: Record = {}; + + const getDocketEntriesFunctions = docketNumbers.map( + docketNumber => async () => { + const docketEntries = (await queryFull({ + ExpressionAttributeNames: { + '#pk': 'pk', + '#sk': 'sk', + }, + ExpressionAttributeValues: { + ':pk': `case|${docketNumber}`, + ':prefix': 'docket-entry|', + }, + KeyConditionExpression: '#pk = :pk AND begins_with(#sk, :prefix)', + applicationContext, + })) as RawDocketEntry[]; + + const correspondence = (await queryFull({ + ExpressionAttributeNames: { + '#pk': 'pk', + '#sk': 'sk', + }, + ExpressionAttributeValues: { + ':pk': `case|${docketNumber}`, + ':prefix': 'correspondence|', + }, + KeyConditionExpression: '#pk = :pk AND begins_with(#sk, :prefix)', + applicationContext, + })) as RawCorrespondence[]; + + docketEntryIdsByDocketNumber[docketNumber] = docketEntries.map( + docketEntry => docketEntry.docketEntryId, + ); + + correspondenceIdsByDocketNumber[docketNumber] = correspondence.map( + correspondence => correspondence.correspondenceId, + ); + }, + ); + + await priorityQueue.addAll(getDocketEntriesFunctions); + return { docketEntryIdsByDocketNumber, correspondenceIdsByDocketNumber }; +}; + +const removePoisonAttachmentsFromMessages = async ({ + messageFragments, + docketEntryIdsByDocketNumber, + correspondenceIdsByDocketNumber, +}: { + messageFragments: MessageFragment[]; + docketEntryIdsByDocketNumber: Record; + correspondenceIdsByDocketNumber: Record; +}): Promise<{ + deletedAttachmentAuditRecords: { + messageId: string; + docketEntryId: string; + }[]; + updatedMessageFragments: MessageFragment[]; +}> => { + const updatedMessageFragments: MessageFragment[] = []; + const deletedAttachmentAuditRecords: { + messageId: string; + docketEntryId: string; + }[] = []; + for (const message of messageFragments) { + if (message.attachments) { + for (const attachment of message.attachments) { + if ( + !docketEntryIdsByDocketNumber[message.docketNumber]?.includes( + attachment.documentId, + ) && + !correspondenceIdsByDocketNumber[message.docketNumber]?.includes( + attachment.documentId, + ) + ) { + deletedAttachmentAuditRecords.push({ + messageId: message.messageId, + docketEntryId: attachment.documentId, + }); + console.log( + `Removing attachment ${attachment.documentId} from message ${message.messageId}`, + ); + message.attachments = message.attachments?.filter( + att => att.documentId !== attachment.documentId, + ); + updatedMessageFragments.push(message); + } + } + } + } + + return { + deletedAttachmentAuditRecords, + updatedMessageFragments, + }; +}; + +const udpateMessagesInDb = async ( + db: Kysely, + updatedMessageFragments: MessageFragment[], +) => { + await db.transaction().execute(async trx => { + for (const message of updatedMessageFragments) { + await trx + .updateTable('dwMessage') + .set({ attachments: JSON.stringify(message.attachments) }) + .where('messageId', '=', message.messageId) + .execute(); + } + }); +}; + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +(async () => { + const applicationContext: ServerApplicationContext = createApplicationContext( + {}, + ); + + const { liveRun } = parseArguments(scriptConfig); + + const sourceSigner = new Signer({ + hostname: DB_HOST!, + port: DB_PORT, + region: 'us-east-1', + username: DB_USER!, + }); + const sourcePassword = await sourceSigner.getAuthToken(); + + const config = { + database: DB_NAME, + host: DB_HOST, + idleTimeoutMillis: 1000, + max: 1, + password: sourcePassword, + port: DB_PORT, + user: DB_USER, + ssl: { + ca: fs.readFileSync('global-bundle.pem').toString(), + }, + }; + + const db = await connect(config); + + console.log('Fetching messages that have not been replied to...'); + const messageFragments = await db + .selectFrom('dwMessage') + .select(['attachments', 'messageId', 'docketNumber']) + .execute(); + + // collect all unique docket numbers from messages + console.log('Collecting unique docket numbers from messages...'); + const docketNumbers = Array.from( + new Set(messageFragments.map(message => message.docketNumber)), + ); + + const { docketEntryIdsByDocketNumber, correspondenceIdsByDocketNumber } = + await getDocketEntryIdsByDocketNumbers({ + applicationContext, + docketNumbers, + }); + + const { deletedAttachmentAuditRecords, updatedMessageFragments } = + await removePoisonAttachmentsFromMessages({ + docketEntryIdsByDocketNumber, + messageFragments, + correspondenceIdsByDocketNumber, + }); + + if (liveRun) { + console.log(`Updating ${updatedMessageFragments.length} messages in DB...`); + await udpateMessagesInDb(db, updatedMessageFragments); + } + + const auditFilename = 'corruptMessageCleanupAudit.json'; + fs.writeFileSync( + auditFilename, + JSON.stringify(deletedAttachmentAuditRecords, null, 2), + ); + + console.log( + '------------------------------------------------------------------', + ); + console.log( + `A log of attachments removed can be found here: ${path.resolve(__dirname, auditFilename)}`, + ); + console.log( + 'Removed attachments count: ', + deletedAttachmentAuditRecords.length, + ); + console.log('Impacted messages count: ', updatedMessageFragments.length); +})(); diff --git a/scripts/run-once-scripts/postgres-migration/batch-delete-dynamo-items.ts b/scripts/run-once-scripts/postgres-migration/batch-delete-dynamo-items.ts new file mode 100644 index 00000000000..60284d20ad4 --- /dev/null +++ b/scripts/run-once-scripts/postgres-migration/batch-delete-dynamo-items.ts @@ -0,0 +1,63 @@ +import { + BatchWriteCommand, + DynamoDBDocumentClient, +} from '@aws-sdk/lib-dynamodb'; + +export async function batchDeleteDynamoItems( + itemsToDelete: { DeleteRequest: { Key: { pk: string; sk: string } } }[], + client: DynamoDBDocumentClient, + tableNameInput: string, +): Promise { + const BATCH_SIZE = 25; + const RETRY_DELAY_MS = 5000; // Set the delay between retries (in milliseconds) + let totalItemsDeleted = 0; + + for (let i = 0; i < itemsToDelete.length; i += BATCH_SIZE) { + const batch = itemsToDelete.slice(i, i + BATCH_SIZE); + + const batchWriteParams = { + RequestItems: { + [tableNameInput]: batch, + }, + }; + + try { + let unprocessedItems: any[] = batch; + let retryCount = 0; + const MAX_RETRIES = 5; + + // Retry logic for unprocessed items + while (unprocessedItems.length > 0 && retryCount < MAX_RETRIES) { + const response = await client.send( + new BatchWriteCommand(batchWriteParams), + ); + + totalItemsDeleted += + unprocessedItems.length - + (response.UnprocessedItems?.[tableNameInput]?.length || 0); + + unprocessedItems = response.UnprocessedItems?.[tableNameInput] ?? []; + + if (unprocessedItems.length > 0) { + console.log( + `Retrying unprocessed items: ${unprocessedItems.length}, attempt ${retryCount + 1}`, + ); + batchWriteParams.RequestItems[tableNameInput] = unprocessedItems; + retryCount++; + + // Add delay before the next retry + await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS)); + } + } + + if (unprocessedItems.length > 0) { + console.error( + `Failed to delete ${unprocessedItems.length} items after ${MAX_RETRIES} retries.`, + ); + } + } catch (error) { + console.error('Error in batch delete:', error); + } + } + return totalItemsDeleted; +} diff --git a/scripts/run-once-scripts/postgres-migration/delete-case-notes.ts b/scripts/run-once-scripts/postgres-migration/delete-case-notes.ts new file mode 100644 index 00000000000..3fb02442769 --- /dev/null +++ b/scripts/run-once-scripts/postgres-migration/delete-case-notes.ts @@ -0,0 +1,60 @@ +/** + * HOW TO RUN + * npx ts-node --transpileOnly scripts/run-once-scripts/postgres-migration/delete-case-notes.ts + */ + +import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { getDbReader } from '../../../web-api/src/database'; +import { isEmpty } from 'lodash'; +import { batchDeleteDynamoItems } from './batch-delete-dynamo-items'; +import { environment } from '../../../web-api/src/environment'; + +const caseUserNotesPageSize = 10000; +const dynamoDbClient = new DynamoDBClient({ region: 'us-east-1' }); +const dynamoDbDocClient = DynamoDBDocumentClient.from(dynamoDbClient); + +// We set the environment as 'production' (= "a deployed environment") to get the RDS connection to work properly +environment.nodeEnv = 'production'; + +const getCaseNotesToDelete = async (offset: number) => { + const caseNotes = await getDbReader(reader => + reader + .selectFrom('dwUserCaseNote') + .select(['docketNumber', 'userId']) + .orderBy(['docketNumber', 'userId']) + .limit(caseUserNotesPageSize) + .offset(offset) + .execute(), + ); + return caseNotes; +}; + +let totalItemsDeleted = 0; + +async function main() { + let offset = 0; + let caseNotesToDelete = await getCaseNotesToDelete(offset); + + while (!isEmpty(caseNotesToDelete)) { + const dynamoItemsToDelete = caseNotesToDelete.map(c => ({ + DeleteRequest: { + Key: { + pk: `user-case-note|${c.docketNumber}`, + sk: `user|${c.userId}`, + }, + }, + })); + totalItemsDeleted += await batchDeleteDynamoItems( + dynamoItemsToDelete, + dynamoDbDocClient, + environment.dynamoDbTableName, + ); + console.log(`Total case notes deleted so far: ${totalItemsDeleted}`); + offset += caseUserNotesPageSize; + caseNotesToDelete = await getCaseNotesToDelete(offset); + } + console.log('Done deleting case notes from Dynamo'); +} + +main().catch(console.error); diff --git a/scripts/run-once-scripts/delete-messages.ts b/scripts/run-once-scripts/postgres-migration/delete-messages.ts similarity index 98% rename from scripts/run-once-scripts/delete-messages.ts rename to scripts/run-once-scripts/postgres-migration/delete-messages.ts index 6ed1a66b356..cc022e06f85 100644 --- a/scripts/run-once-scripts/delete-messages.ts +++ b/scripts/run-once-scripts/postgres-migration/delete-messages.ts @@ -10,7 +10,7 @@ import { ScanCommand, } from '@aws-sdk/lib-dynamodb'; import { DynamoDBClient, ScanCommandInput } from '@aws-sdk/client-dynamodb'; -import { requireEnvVars } from '../../shared/admin-tools/util'; +import { requireEnvVars } from '../../../shared/admin-tools/util'; requireEnvVars(['TABLE_NAME']); diff --git a/shared/src/business/entities/factories/UserFactory.test.ts b/shared/src/business/entities/factories/UserFactory.test.ts new file mode 100644 index 00000000000..fcdf2adcc44 --- /dev/null +++ b/shared/src/business/entities/factories/UserFactory.test.ts @@ -0,0 +1,36 @@ +import { Practitioner } from '@shared/business/entities/Practitioner'; +import { ROLES } from '@shared/business/entities/EntityConstants'; +import { User } from '@shared/business/entities/User'; +import { UserFactory } from '@shared/business/entities/factories/UserFactory'; + +describe('UserFactory', () => { + describe('getClass', () => { + it('should return "Practitioner" class type if role is "privatePractitioner"', () => { + const TEST_USER = { role: ROLES.privatePractitioner }; + const userFactory = new UserFactory(TEST_USER); + const classInstance = userFactory.getClass(); + expect(classInstance).toEqual(Practitioner); + }); + + it('should return "Practitioner" class type if role is "irsPractitioner"', () => { + const TEST_USER = { role: ROLES.irsPractitioner }; + const userFactory = new UserFactory(TEST_USER); + const classInstance = userFactory.getClass(); + expect(classInstance).toEqual(Practitioner); + }); + + it('should return "Practitioner" class type if role is "inactivePractitioner"', () => { + const TEST_USER = { role: ROLES.inactivePractitioner }; + const userFactory = new UserFactory(TEST_USER); + const classInstance = userFactory.getClass(); + expect(classInstance).toEqual(Practitioner); + }); + + it('should return "User" class type if role is "admin"', () => { + const TEST_USER = { role: ROLES.admin }; + const userFactory = new UserFactory(TEST_USER); + const classInstance = userFactory.getClass(); + expect(classInstance).toEqual(User); + }); + }); +}); diff --git a/shared/src/business/entities/factories/UserFactory.ts b/shared/src/business/entities/factories/UserFactory.ts new file mode 100644 index 00000000000..7a7617196e6 --- /dev/null +++ b/shared/src/business/entities/factories/UserFactory.ts @@ -0,0 +1,27 @@ +import { Practitioner } from '@shared/business/entities/Practitioner'; +import { ROLES, Role } from '@shared/business/entities/EntityConstants'; +import { User } from '@shared/business/entities/User'; + +type MinimalFactoryInfo = { + role: Role; +}; + +export class UserFactory { + private rawUser: MinimalFactoryInfo; + + constructor(rawUser: MinimalFactoryInfo) { + this.rawUser = rawUser; + } + + public getClass(): typeof User | typeof Practitioner { + if ( + this.rawUser.role === ROLES.privatePractitioner || + this.rawUser.role === ROLES.irsPractitioner || + this.rawUser.role === ROLES.inactivePractitioner + ) { + return Practitioner; + } + + return User; + } +} diff --git a/shared/src/proxies/users/verifyUserPendingEmailProxy.ts b/shared/src/proxies/users/verifyUserPendingEmailProxy.ts index 7c0ac503e53..4d42e34b8d4 100644 --- a/shared/src/proxies/users/verifyUserPendingEmailProxy.ts +++ b/shared/src/proxies/users/verifyUserPendingEmailProxy.ts @@ -10,8 +10,8 @@ import { put } from '../requests'; */ export const verifyUserPendingEmailInteractor = ( applicationContext, - { token }, -) => { + { token }: { token: string }, +): Promise => { return put({ applicationContext, body: { diff --git a/types/TEntity.d.ts b/types/TEntity.d.ts index 83013e86ea9..cffd14bcce3 100644 --- a/types/TEntity.d.ts +++ b/types/TEntity.d.ts @@ -27,12 +27,6 @@ type TPetitioner = { hasConsentedToEService?: boolean; }; -type TCaseNote = { - userId: string; - docketNumber: string; - notes: string; -}; - interface IValidateRawCollection { (collection: I[], options: { applicationContext: IApplicationContext }): I[]; } diff --git a/web-api/elasticsearch/efcms-case-mappings.ts b/web-api/elasticsearch/efcms-case-mappings.ts index c637e0c693f..9f381b87937 100644 --- a/web-api/elasticsearch/efcms-case-mappings.ts +++ b/web-api/elasticsearch/efcms-case-mappings.ts @@ -70,6 +70,9 @@ export const efcmsCaseMappings = { 'indexedTimestamp.N': { type: 'text', }, + 'irsPractitioners.L.M.email.S': { + type: 'keyword', + }, 'irsPractitioners.L.M.userId.S': { type: 'keyword', }, @@ -89,6 +92,9 @@ export const efcmsCaseMappings = { 'petitioners.L.M.countryType.S': { type: 'keyword', }, + 'petitioners.L.M.email.S': { + type: 'keyword', + }, 'petitioners.L.M.name.S': { type: 'text', }, @@ -104,6 +110,9 @@ export const efcmsCaseMappings = { 'preferredTrialCity.S': { type: 'keyword', }, + 'privatePractitioners.L.M.email.S': { + type: 'keyword', + }, 'privatePractitioners.L.M.userId.S': { type: 'keyword', }, diff --git a/web-api/src/business/useCases/auth/changePasswordInteractor.test.ts b/web-api/src/business/useCases/auth/changePasswordInteractor.test.ts index c1a3d740785..372c6f5f0e4 100644 --- a/web-api/src/business/useCases/auth/changePasswordInteractor.test.ts +++ b/web-api/src/business/useCases/auth/changePasswordInteractor.test.ts @@ -6,7 +6,7 @@ import { UserStatusType, } from '@aws-sdk/client-cognito-identity-provider'; import { MESSAGE_TYPES } from '@web-api/gateways/worker/workerRouter'; -import { MOCK_PRACTITIONER } from '@shared/test/mockUsers'; +import { MOCK_PRACTITIONER, petitionerUser } from '@shared/test/mockUsers'; import { ROLES, Role, @@ -14,7 +14,10 @@ import { } from '../../../../../shared/src/business/entities/EntityConstants'; import { UserRecord } from '@web-api/persistence/dynamo/dynamoTypes'; import { applicationContext } from '../../../../../shared/src/business/test/createTestApplicationContext'; -import { changePasswordInteractor } from './changePasswordInteractor'; +import { + changePasswordInteractor, + updateUserPendingEmailRecord, +} from './changePasswordInteractor'; import jwt from 'jsonwebtoken'; describe('changePasswordInteractor', () => { @@ -326,3 +329,45 @@ describe('changePasswordInteractor', () => { }); }); }); + +describe('updateUserPendingEmailRecord', () => { + beforeEach(() => { + applicationContext + .getPersistenceGateway() + .updateUser.mockResolvedValue(null); + }); + + it('should set isUpdatingInformation to true if flag is enabled', async () => { + await updateUserPendingEmailRecord(applicationContext, { + setIsUpdatingInformation: true, + user: { + ...petitionerUser, + isUpdatingInformation: false, + }, + }); + + const updateUserCalls = + applicationContext.getPersistenceGateway().updateUser.mock.calls; + expect(updateUserCalls.length).toEqual(1); + expect(updateUserCalls[0][0].user).toMatchObject({ + isUpdatingInformation: true, + }); + }); + + it('should not update isUpdatingInformation property when flag is disabled', async () => { + await updateUserPendingEmailRecord(applicationContext, { + setIsUpdatingInformation: false, + user: { + ...petitionerUser, + isUpdatingInformation: false, + }, + }); + + const updateUserCalls = + applicationContext.getPersistenceGateway().updateUser.mock.calls; + expect(updateUserCalls.length).toEqual(1); + expect(updateUserCalls[0][0].user).toMatchObject({ + isUpdatingInformation: false, + }); + }); +}); diff --git a/web-api/src/business/useCases/auth/changePasswordInteractor.ts b/web-api/src/business/useCases/auth/changePasswordInteractor.ts index 15a1903edbd..2982c21188d 100644 --- a/web-api/src/business/useCases/auth/changePasswordInteractor.ts +++ b/web-api/src/business/useCases/auth/changePasswordInteractor.ts @@ -123,7 +123,10 @@ export const changePasswordInteractor = async ( export const updateUserPendingEmailRecord = async ( applicationContext: ServerApplicationContext, - { user }: { user: RawUser }, + { + setIsUpdatingInformation = false, + user, + }: { user: RawUser; setIsUpdatingInformation?: boolean }, ): Promise<{ updatedUser: RawPractitioner | RawUser }> => { let userEntity; @@ -150,6 +153,8 @@ export const updateUserPendingEmailRecord = async ( }); } + if (setIsUpdatingInformation) userEntity.isUpdatingInformation = true; + const rawUser = userEntity.validate().toRawObject(); await applicationContext.getPersistenceGateway().updateUser({ applicationContext, diff --git a/web-api/src/business/useCases/caseNote/deleteUserCaseNoteInteractor.test.ts b/web-api/src/business/useCases/caseNote/deleteUserCaseNoteInteractor.test.ts index d47eb43b837..38d94ce23f8 100644 --- a/web-api/src/business/useCases/caseNote/deleteUserCaseNoteInteractor.test.ts +++ b/web-api/src/business/useCases/caseNote/deleteUserCaseNoteInteractor.test.ts @@ -1,13 +1,17 @@ +import '@web-api/persistence/postgres/userCaseNotes/mocks.jest'; import { ROLES } from '../../../../../shared/src/business/entities/EntityConstants'; import { UnauthorizedError } from '@web-api/errors/errors'; import { UnknownAuthUser } from '@shared/business/entities/authUser/AuthUser'; import { User } from '../../../../../shared/src/business/entities/User'; import { applicationContext } from '../../../../../shared/src/business/test/createTestApplicationContext'; import { deleteUserCaseNoteInteractor } from './deleteUserCaseNoteInteractor'; +import { deleteUserCaseNote as deleteUserCaseNoteMock } from '@web-api/persistence/postgres/userCaseNotes/deleteUserCaseNote'; import { mockJudgeUser } from '@shared/test/mockAuthUsers'; import { omit } from 'lodash'; describe('deleteUserCaseNoteInteractor', () => { + const deleteUserCaseNote = deleteUserCaseNoteMock as jest.Mock; + it('throws an error if the user is not valid or authorized', async () => { let user = {} as UnknownAuthUser; @@ -33,7 +37,7 @@ describe('deleteUserCaseNoteInteractor', () => { applicationContext .getPersistenceGateway() .getUserById.mockReturnValue(mockUser); - applicationContext.getPersistenceGateway().deleteUserCaseNote = v => v; + deleteUserCaseNote.mockImplementation(v => v); applicationContext .getUseCaseHelpers() .getJudgeInSectionHelper.mockReturnValue({ @@ -60,7 +64,6 @@ describe('deleteUserCaseNoteInteractor', () => { applicationContext .getPersistenceGateway() .getUserById.mockReturnValue(mockUser); - applicationContext.getPersistenceGateway().deleteUserCaseNote = jest.fn(); applicationContext .getUseCaseHelpers() .getJudgeInSectionHelper.mockReturnValue(null); @@ -72,9 +75,8 @@ describe('deleteUserCaseNoteInteractor', () => { omit(mockUser, 'section'), ); - expect( - applicationContext.getPersistenceGateway().deleteUserCaseNote.mock - .calls[0][0].userId, - ).toEqual(mockJudgeUser.userId); + expect(deleteUserCaseNote.mock.calls[0][0].userId).toEqual( + mockJudgeUser.userId, + ); }); }); diff --git a/web-api/src/business/useCases/caseNote/deleteUserCaseNoteInteractor.ts b/web-api/src/business/useCases/caseNote/deleteUserCaseNoteInteractor.ts index 65809d66eaf..4445f8c31a8 100644 --- a/web-api/src/business/useCases/caseNote/deleteUserCaseNoteInteractor.ts +++ b/web-api/src/business/useCases/caseNote/deleteUserCaseNoteInteractor.ts @@ -5,6 +5,7 @@ import { import { ServerApplicationContext } from '@web-api/applicationContext'; import { UnauthorizedError } from '@web-api/errors/errors'; import { UnknownAuthUser } from '@shared/business/entities/authUser/AuthUser'; +import { deleteUserCaseNote } from '@web-api/persistence/postgres/userCaseNotes/deleteUserCaseNote'; /** * deleteUserCaseNoteInteractor @@ -31,8 +32,7 @@ export const deleteUserCaseNoteInteractor = async ( userIdMakingRequest: authorizedUser.userId, }); - return await applicationContext.getPersistenceGateway().deleteUserCaseNote({ - applicationContext, + return await deleteUserCaseNote({ docketNumber, userId, }); diff --git a/web-api/src/business/useCases/caseNote/getUserCaseNoteForCasesInteractor.test.ts b/web-api/src/business/useCases/caseNote/getUserCaseNoteForCasesInteractor.test.ts index 31f054dfec4..7f07f74b45f 100644 --- a/web-api/src/business/useCases/caseNote/getUserCaseNoteForCasesInteractor.test.ts +++ b/web-api/src/business/useCases/caseNote/getUserCaseNoteForCasesInteractor.test.ts @@ -1,8 +1,11 @@ +import '@web-api/persistence/postgres/userCaseNotes/mocks.jest'; import { MOCK_CASE } from '../../../../../shared/src/test/mockCase'; import { UnauthorizedError } from '@web-api/errors/errors'; import { UnknownAuthUser } from '@shared/business/entities/authUser/AuthUser'; +import { UserCaseNote } from '@shared/business/entities/notes/UserCaseNote'; import { applicationContext } from '../../../../../shared/src/business/test/createTestApplicationContext'; import { getUserCaseNoteForCasesInteractor } from './getUserCaseNoteForCasesInteractor'; +import { getUserCaseNoteForCases as getUserCaseNoteForCasesMock } from '@web-api/persistence/postgres/userCaseNotes/getUserCaseNoteForCases'; import { mockJudgeUser } from '@shared/test/mockAuthUsers'; import { omit } from 'lodash'; @@ -21,15 +24,15 @@ describe('getUserCaseNoteForCasesInteractor', () => { section: 'colvinChambers', } as UnknownAuthUser; + const getUserCaseNoteForCases = getUserCaseNoteForCasesMock as jest.Mock; + beforeEach(() => { mockCurrentUser = mockJudge; mockNote = MOCK_NOTE; applicationContext .getPersistenceGateway() .getUserById.mockImplementation(() => mockCurrentUser); - applicationContext - .getPersistenceGateway() - .getUserCaseNoteForCases.mockResolvedValue([mockNote]); + getUserCaseNoteForCases.mockResolvedValue([new UserCaseNote(mockNote)]); applicationContext .getUseCaseHelpers() .getJudgeInSectionHelper.mockReturnValue(mockJudge); @@ -52,9 +55,9 @@ describe('getUserCaseNoteForCasesInteractor', () => { }); it('throws an error if the entity returned from persistence is invalid', async () => { - applicationContext - .getPersistenceGateway() - .getUserCaseNoteForCases.mockResolvedValue([omit(MOCK_NOTE, 'userId')]); + getUserCaseNoteForCases.mockResolvedValue([ + new UserCaseNote([omit(MOCK_NOTE, 'userId')]), + ]); await expect( getUserCaseNoteForCasesInteractor( @@ -100,9 +103,8 @@ describe('getUserCaseNoteForCasesInteractor', () => { omit(mockUser, 'section'), ); - expect( - applicationContext.getPersistenceGateway().getUserCaseNoteForCases.mock - .calls[0][0].userId, - ).toEqual(userIdToExpect); + expect(getUserCaseNoteForCases.mock.calls[0][0].userId).toEqual( + userIdToExpect, + ); }); }); diff --git a/web-api/src/business/useCases/caseNote/getUserCaseNoteForCasesInteractor.ts b/web-api/src/business/useCases/caseNote/getUserCaseNoteForCasesInteractor.ts index 91257ab2877..613b2779468 100644 --- a/web-api/src/business/useCases/caseNote/getUserCaseNoteForCasesInteractor.ts +++ b/web-api/src/business/useCases/caseNote/getUserCaseNoteForCasesInteractor.ts @@ -4,7 +4,7 @@ import { } from '../../../../../shared/src/authorization/authorizationClientService'; import { UnauthorizedError } from '@web-api/errors/errors'; import { UnknownAuthUser } from '@shared/business/entities/authUser/AuthUser'; -import { UserCaseNote } from '../../../../../shared/src/business/entities/notes/UserCaseNote'; +import { getUserCaseNoteForCases } from '@web-api/persistence/postgres/userCaseNotes/getUserCaseNoteForCases'; export const getUserCaseNoteForCasesInteractor = async ( applicationContext, @@ -21,13 +21,10 @@ export const getUserCaseNoteForCasesInteractor = async ( userIdMakingRequest: authorizedUser.userId, }); - const caseNotes = await applicationContext - .getPersistenceGateway() - .getUserCaseNoteForCases({ - applicationContext, - docketNumbers, - userId, - }); + const caseNotes = await getUserCaseNoteForCases({ + docketNumbers, + userId, + }); - return caseNotes.map(note => new UserCaseNote(note).validate().toRawObject()); + return caseNotes.map(note => note.validate().toRawObject()); }; diff --git a/web-api/src/business/useCases/caseNote/getUserCaseNoteInteractor.test.ts b/web-api/src/business/useCases/caseNote/getUserCaseNoteInteractor.test.ts index c1e25eacca5..4d441df7265 100644 --- a/web-api/src/business/useCases/caseNote/getUserCaseNoteInteractor.test.ts +++ b/web-api/src/business/useCases/caseNote/getUserCaseNoteInteractor.test.ts @@ -1,9 +1,11 @@ +import '@web-api/persistence/postgres/userCaseNotes/mocks.jest'; import { MOCK_CASE } from '../../../../../shared/src/test/mockCase'; import { UnauthorizedError } from '@web-api/errors/errors'; import { UnknownAuthUser } from '@shared/business/entities/authUser/AuthUser'; import { User } from '../../../../../shared/src/business/entities/User'; import { applicationContext } from '../../../../../shared/src/business/test/createTestApplicationContext'; import { getUserCaseNoteInteractor } from './getUserCaseNoteInteractor'; +import { getUserCaseNote as getUserCaseNoteMock } from '@web-api/persistence/postgres/userCaseNotes/getUserCaseNote'; import { mockJudgeUser } from '@shared/test/mockAuthUsers'; import { omit } from 'lodash'; @@ -19,13 +21,13 @@ describe('Get case note', () => { userId: 'unauthorizedUser', } as unknown as UnknownAuthUser; + const getUserCaseNote = getUserCaseNoteMock as jest.Mock; + it('throws error if user is unauthorized', async () => { applicationContext .getPersistenceGateway() .getUserById.mockImplementation(() => new User(mockUnauthorizedUser)); - applicationContext - .getPersistenceGateway() - .getUserCaseNote.mockReturnValue({}); + getUserCaseNote.mockReturnValue({}); applicationContext .getUseCaseHelpers() .getJudgeInSectionHelper.mockReturnValue(null); @@ -45,9 +47,7 @@ describe('Get case note', () => { applicationContext .getPersistenceGateway() .getUserById.mockImplementation(() => new User(mockJudgeUser)); - applicationContext - .getPersistenceGateway() - .getUserCaseNote.mockResolvedValue(omit(MOCK_NOTE, 'userId')); + getUserCaseNote.mockResolvedValue(omit(MOCK_NOTE, 'userId')); applicationContext .getUseCaseHelpers() .getJudgeInSectionHelper.mockReturnValue(mockJudgeUser); @@ -67,9 +67,7 @@ describe('Get case note', () => { applicationContext .getPersistenceGateway() .getUserById.mockImplementation(() => new User(mockJudgeUser)); - applicationContext - .getPersistenceGateway() - .getUserCaseNote.mockResolvedValue(MOCK_NOTE); + getUserCaseNote.mockResolvedValue(MOCK_NOTE); applicationContext .getUseCaseHelpers() .getJudgeInSectionHelper.mockReturnValue(mockJudgeUser); @@ -89,9 +87,7 @@ describe('Get case note', () => { applicationContext .getPersistenceGateway() .getUserById.mockImplementation(() => new User(mockJudgeUser)); - applicationContext - .getPersistenceGateway() - .getUserCaseNote.mockResolvedValue(MOCK_NOTE); + getUserCaseNote.mockResolvedValue(MOCK_NOTE); applicationContext .getUseCaseHelpers() .getJudgeInSectionHelper.mockReturnValue(null); @@ -114,9 +110,7 @@ describe('Get case note', () => { applicationContext .getPersistenceGateway() .getUserById.mockImplementation(() => new User(mockJudgeUser)); - applicationContext - .getPersistenceGateway() - .getUserCaseNote.mockReturnValue(null); + getUserCaseNote.mockReturnValue(null); applicationContext .getUseCaseHelpers() .getJudgeInSectionHelper.mockReturnValue(mockJudgeUser); diff --git a/web-api/src/business/useCases/caseNote/getUserCaseNoteInteractor.ts b/web-api/src/business/useCases/caseNote/getUserCaseNoteInteractor.ts index d7c1c37af7d..17bd2fb6e29 100644 --- a/web-api/src/business/useCases/caseNote/getUserCaseNoteInteractor.ts +++ b/web-api/src/business/useCases/caseNote/getUserCaseNoteInteractor.ts @@ -6,6 +6,7 @@ import { ServerApplicationContext } from '@web-api/applicationContext'; import { UnauthorizedError } from '@web-api/errors/errors'; import { UnknownAuthUser } from '@shared/business/entities/authUser/AuthUser'; import { UserCaseNote } from '../../../../../shared/src/business/entities/notes/UserCaseNote'; +import { getUserCaseNote } from '@web-api/persistence/postgres/userCaseNotes/getUserCaseNote'; /** * getUserCaseNoteInteractor @@ -32,13 +33,10 @@ export const getUserCaseNoteInteractor = async ( userIdMakingRequest: authorizedUser.userId, }); - const caseNote = await applicationContext - .getPersistenceGateway() - .getUserCaseNote({ - applicationContext, - docketNumber, - userId, - }); + const caseNote = await getUserCaseNote({ + docketNumber, + userId, + }); if (caseNote) { return new UserCaseNote(caseNote).validate().toRawObject(); diff --git a/web-api/src/business/useCases/caseNote/updateUserCaseNoteInteractor.test.ts b/web-api/src/business/useCases/caseNote/updateUserCaseNoteInteractor.test.ts index d907227899e..3e1e541f59c 100644 --- a/web-api/src/business/useCases/caseNote/updateUserCaseNoteInteractor.test.ts +++ b/web-api/src/business/useCases/caseNote/updateUserCaseNoteInteractor.test.ts @@ -1,3 +1,4 @@ +import '@web-api/persistence/postgres/userCaseNotes/mocks.jest'; import { ROLES } from '../../../../../shared/src/business/entities/EntityConstants'; import { UnauthorizedError } from '@web-api/errors/errors'; import { UnknownAuthUser } from '@shared/business/entities/authUser/AuthUser'; @@ -5,6 +6,7 @@ import { applicationContext } from '../../../../../shared/src/business/test/crea import { mockJudgeUser } from '@shared/test/mockAuthUsers'; import { omit } from 'lodash'; import { updateUserCaseNoteInteractor } from './updateUserCaseNoteInteractor'; +import { upsertUserCaseNote as upsertUserCaseNoteMock } from '@web-api/persistence/postgres/userCaseNotes/upsertUserCaseNote'; describe('updateUserCaseNoteInteractor', () => { const mockCaseNote = { @@ -13,6 +15,8 @@ describe('updateUserCaseNoteInteractor', () => { userId: '6805d1ab-18d0-43ec-bafb-654e83405416', }; + const upsertUserCaseNote = upsertUserCaseNoteMock as jest.Mock; + it('throws an error if the user is not valid or authorized', async () => { await expect( updateUserCaseNoteInteractor( @@ -34,9 +38,7 @@ describe('updateUserCaseNoteInteractor', () => { applicationContext .getPersistenceGateway() .getUserById.mockImplementation(() => mockUser); - applicationContext - .getPersistenceGateway() - .updateUserCaseNote.mockImplementation(v => v.caseNoteToUpdate); + upsertUserCaseNote.mockImplementation(v => v.caseNoteToUpsert); applicationContext .getUseCaseHelpers() .getJudgeInSectionHelper.mockReturnValue({ @@ -67,7 +69,6 @@ describe('updateUserCaseNoteInteractor', () => { applicationContext .getPersistenceGateway() .getUserById.mockImplementation(() => mockUser); - applicationContext.getPersistenceGateway().updateUserCaseNote = jest.fn(); applicationContext .getUseCaseHelpers() .getJudgeInSectionHelper.mockReturnValue(null); @@ -81,9 +82,8 @@ describe('updateUserCaseNoteInteractor', () => { omit(mockUser, 'section'), ); - expect( - applicationContext.getPersistenceGateway().updateUserCaseNote.mock - .calls[0][0].caseNoteToUpdate.userId, - ).toEqual(userIdToExpect); + expect(upsertUserCaseNote.mock.calls[0][0].caseNoteToUpsert.userId).toEqual( + userIdToExpect, + ); }); }); diff --git a/web-api/src/business/useCases/caseNote/updateUserCaseNoteInteractor.ts b/web-api/src/business/useCases/caseNote/updateUserCaseNoteInteractor.ts index b4894a0a629..e7b996fa74e 100644 --- a/web-api/src/business/useCases/caseNote/updateUserCaseNoteInteractor.ts +++ b/web-api/src/business/useCases/caseNote/updateUserCaseNoteInteractor.ts @@ -5,6 +5,7 @@ import { import { UnauthorizedError } from '@web-api/errors/errors'; import { UnknownAuthUser } from '@shared/business/entities/authUser/AuthUser'; import { UserCaseNote } from '../../../../../shared/src/business/entities/notes/UserCaseNote'; +import { upsertUserCaseNote } from '@web-api/persistence/postgres/userCaseNotes/upsertUserCaseNote'; export const updateUserCaseNoteInteractor = async ( applicationContext, @@ -27,14 +28,11 @@ export const updateUserCaseNoteInteractor = async ( docketNumber, notes, userId, - }); - - const caseNoteToUpdate = caseNoteEntity.validate().toRawObject(); + }).validate(); - await applicationContext.getPersistenceGateway().updateUserCaseNote({ - applicationContext, - caseNoteToUpdate, + await upsertUserCaseNote({ + caseNoteToUpsert: caseNoteEntity, }); - return caseNoteToUpdate; + return caseNoteEntity; }; diff --git a/web-api/src/business/useCases/processStreamRecords/processStreamRecordsInteractor.test.ts b/web-api/src/business/useCases/processStreamRecords/processStreamRecordsInteractor.test.ts index 2ee5a87fc45..62a30a45b8e 100644 --- a/web-api/src/business/useCases/processStreamRecords/processStreamRecordsInteractor.test.ts +++ b/web-api/src/business/useCases/processStreamRecords/processStreamRecordsInteractor.test.ts @@ -5,6 +5,7 @@ jest.mock('./processPractitionerMappingEntries'); jest.mock('./processRemoveEntries'); jest.mock('./processWorkItemEntries'); jest.mock('./processCaseEntries'); +jest.mock('./processUserCaseNoteEntries'); jest.mock('./processOtherEntries'); import { applicationContext } from '../../../../../shared/src/business/test/createTestApplicationContext'; import { partitionRecords } from './processStreamUtilities'; @@ -15,6 +16,7 @@ import { processOtherEntries } from './processOtherEntries'; import { processPractitionerMappingEntries } from './processPractitionerMappingEntries'; import { processRemoveEntries } from './processRemoveEntries'; import { processStreamRecordsInteractor } from './processStreamRecordsInteractor'; +import { processUserCaseNoteEntries } from './processUserCaseNoteEntries'; import { processWorkItemEntries } from './processWorkItemEntries'; describe('processStreamRecordsInteractor', () => { @@ -25,6 +27,7 @@ describe('processStreamRecordsInteractor', () => { (processWorkItemEntries as jest.Mock).mockResolvedValue([]); (processMessageEntries as jest.Mock).mockResolvedValue([]); (processPractitionerMappingEntries as jest.Mock).mockResolvedValue([]); + (processUserCaseNoteEntries as jest.Mock).mockResolvedValue([]); (processOtherEntries as jest.Mock).mockResolvedValue([]); (partitionRecords as jest.Mock).mockReturnValue({ @@ -34,6 +37,7 @@ describe('processStreamRecordsInteractor', () => { otherRecords: [], privatePractitionerMappingRecords: [], removeRecords: [], + userCaseNoteRecords: [], workItemRecords: [], }); }); @@ -60,6 +64,7 @@ describe('processStreamRecordsInteractor', () => { expect(processDocketEntries).not.toHaveBeenCalled(); expect(processWorkItemEntries).not.toHaveBeenCalled(); expect(processMessageEntries).not.toHaveBeenCalled(); + expect(processUserCaseNoteEntries).not.toHaveBeenCalled(); expect(processPractitionerMappingEntries).not.toHaveBeenCalled(); expect(processOtherEntries).not.toHaveBeenCalled(); expect(applicationContext.logger.error).toHaveBeenCalledTimes(2); diff --git a/web-api/src/business/useCases/processStreamRecords/processStreamRecordsInteractor.ts b/web-api/src/business/useCases/processStreamRecords/processStreamRecordsInteractor.ts index 42784a7475e..62bb172816d 100644 --- a/web-api/src/business/useCases/processStreamRecords/processStreamRecordsInteractor.ts +++ b/web-api/src/business/useCases/processStreamRecords/processStreamRecordsInteractor.ts @@ -7,6 +7,7 @@ import { processMessageEntries } from './processMessageEntries'; import { processOtherEntries } from './processOtherEntries'; import { processPractitionerMappingEntries } from './processPractitionerMappingEntries'; import { processRemoveEntries } from './processRemoveEntries'; +import { processUserCaseNoteEntries } from '@web-api/business/useCases/processStreamRecords/processUserCaseNoteEntries'; import { processWorkItemEntries } from './processWorkItemEntries'; import type { DynamoDBRecord } from 'aws-lambda'; @@ -22,6 +23,7 @@ export const processStreamRecordsInteractor = async ( otherRecords, practitionerMappingRecords, removeRecords, + userCaseNoteRecords, workItemRecords, } = partitionRecords(recordsToProcess); @@ -75,6 +77,19 @@ export const processStreamRecordsInteractor = async ( throw err; }); + await processUserCaseNoteEntries({ + applicationContext, + userCaseNoteRecords, + }).catch(err => { + applicationContext.logger.error( + 'failed to process userCaseNote records', + { + err, + }, + ); + throw err; + }); + await processPractitionerMappingEntries({ applicationContext, practitionerMappingRecords, diff --git a/web-api/src/business/useCases/processStreamRecords/processStreamUtilities.ts b/web-api/src/business/useCases/processStreamRecords/processStreamUtilities.ts index 16efeb1abb5..1b5cb1db0b1 100644 --- a/web-api/src/business/useCases/processStreamRecords/processStreamUtilities.ts +++ b/web-api/src/business/useCases/processStreamRecords/processStreamUtilities.ts @@ -48,8 +48,15 @@ export const partitionRecords = ( record.dynamodb.NewImage.entityName.S === 'Message', ); - const [completionMarkers, otherRecords] = partition( + const [userCaseNoteRecords, nonUserCaseNoteRecords] = partition( nonMessageRecords, + record => + record.dynamodb?.NewImage?.entityName && + record.dynamodb.NewImage.entityName.S === 'UserCaseNote', + ); + + const [completionMarkers, otherRecords] = partition( + nonUserCaseNoteRecords, record => record.dynamodb?.NewImage?.entityName && record.dynamodb.NewImage.entityName.S === 'CompletionMarker', @@ -63,6 +70,7 @@ export const partitionRecords = ( otherRecords, practitionerMappingRecords, removeRecords, + userCaseNoteRecords, workItemRecords, }; }; diff --git a/web-api/src/business/useCases/processStreamRecords/processUserCaseNoteEntries.test.ts b/web-api/src/business/useCases/processStreamRecords/processUserCaseNoteEntries.test.ts new file mode 100644 index 00000000000..b62ef80ae62 --- /dev/null +++ b/web-api/src/business/useCases/processStreamRecords/processUserCaseNoteEntries.test.ts @@ -0,0 +1,44 @@ +import '@web-api/persistence/postgres/userCaseNotes/mocks.jest'; +import { applicationContext } from '../../../../../shared/src/business/test/createTestApplicationContext'; +import { processUserCaseNoteEntries } from '@web-api/business/useCases/processStreamRecords/processUserCaseNoteEntries'; +import { upsertUserCaseNotes } from '@web-api/persistence/postgres/userCaseNotes/upsertUserCaseNotes'; + +describe('processUserCaseNoteEntries', () => { + beforeEach(() => { + (upsertUserCaseNotes as jest.Mock).mockResolvedValue(undefined); + }); + + it('should attempt to store the user case notes using the upsert method', async () => { + const mockUserCaseNoteRecord = { + dynamodb: { + NewImage: { + docketNumber: { + S: '104-20', + }, + entityName: { + S: 'UserCaseNote', + }, + notes: { + S: 'Test', + }, + pk: { + S: 'user-case-note|104-20', + }, + sk: { + S: 'user|c4a1a9da-ac90-40f1-8d4d-d494c219cbbe', + }, + userId: { + S: 'c4a1a9da-ac90-40f1-8d4d-d494c219cbbe', + }, + }, + }, + }; + + await processUserCaseNoteEntries({ + applicationContext, + userCaseNoteRecords: [mockUserCaseNoteRecord], + }); + + expect(upsertUserCaseNotes).toHaveBeenCalled(); + }); +}); diff --git a/web-api/src/business/useCases/processStreamRecords/processUserCaseNoteEntries.ts b/web-api/src/business/useCases/processStreamRecords/processUserCaseNoteEntries.ts new file mode 100644 index 00000000000..7cc15c27528 --- /dev/null +++ b/web-api/src/business/useCases/processStreamRecords/processUserCaseNoteEntries.ts @@ -0,0 +1,26 @@ +import { RawUserCaseNote } from '@shared/business/entities/notes/UserCaseNote'; +import { unmarshall } from '@aws-sdk/util-dynamodb'; +import { upsertUserCaseNotes } from '@web-api/persistence/postgres/userCaseNotes/upsertUserCaseNotes'; +import type { ServerApplicationContext } from '@web-api/applicationContext'; + +export const processUserCaseNoteEntries = async ({ + applicationContext, + userCaseNoteRecords, +}: { + applicationContext: ServerApplicationContext; + userCaseNoteRecords: any[]; +}) => { + if (!userCaseNoteRecords.length) return; + + applicationContext.logger.debug( + `going to index ${userCaseNoteRecords.length} userCaseNote records`, + ); + + await upsertUserCaseNotes( + userCaseNoteRecords.map(userCaseNoteRecord => { + return unmarshall( + userCaseNoteRecord.dynamodb.NewImage, + ) as RawUserCaseNote; + }), + ); +}; diff --git a/web-api/src/business/useCases/user/queueEmailUpdateAssociatedCasesWorker.test.ts b/web-api/src/business/useCases/user/queueEmailUpdateAssociatedCasesWorker.test.ts new file mode 100644 index 00000000000..15e3e5137a1 --- /dev/null +++ b/web-api/src/business/useCases/user/queueEmailUpdateAssociatedCasesWorker.test.ts @@ -0,0 +1,179 @@ +import { + MAX_ITERATIONS, + queueEmailUpdateAssociatedCasesWorker, +} from '@web-api/business/useCases/user/queueEmailUpdateAssociatedCasesWorker'; +import { RawUser } from '@shared/business/entities/User'; +import { applicationContext } from '@shared/business/test/createTestApplicationContext'; +import { mockPetitionerUser } from '@shared/test/mockAuthUsers'; +import { petitionerUser } from '@shared/test/mockUsers'; +import { sleep } from '@shared/tools/helpers'; + +describe('queueEmailUpdateAssociatedCasesWorker', () => { + let TEST_USER: RawUser; + let RESOLVER: Function; + + beforeEach(() => { + TEST_USER = { + ...petitionerUser, + isUpdatingInformation: true, + }; + + applicationContext.getPersistenceGateway().updateUser.mockReturnValue(null); + + applicationContext + .getUseCases() + .queueUpdateAssociatedCasesWorker.mockReturnValue(null); + + applicationContext + .getPersistenceGateway() + .getCasesByEmailTotal.mockImplementation( + () => + new Promise(resolve => { + RESOLVER = resolve; + }), + ); + + applicationContext.getUtilities().sleep.mockImplementation(() => {}); + }); + + it('should disable user flag and short circuit if there is no associated cases to user', async () => { + applicationContext + .getPersistenceGateway() + .getDocketNumbersByUser.mockReturnValue([]); + + await queueEmailUpdateAssociatedCasesWorker( + applicationContext, + { user: TEST_USER }, + mockPetitionerUser, + ); + + const updateUserCalls = + applicationContext.getPersistenceGateway().updateUser.mock.calls; + expect(updateUserCalls.length).toEqual(1); + expect(updateUserCalls[0][0].user).toMatchObject({ + isUpdatingInformation: false, + }); + + const queueUpdateAssociatedCasesWorkerCalls = + applicationContext.getUseCases().queueUpdateAssociatedCasesWorker.mock + .calls; + expect(queueUpdateAssociatedCasesWorkerCalls.length).toEqual(0); + }); + + function assertFunctionCalls(expectedCount: number) { + expect( + applicationContext.getPersistenceGateway().getDocketNumbersByUser.mock + .calls.length, + ).toEqual(expectedCount + 1); + + expect( + applicationContext.getPersistenceGateway().getCasesByEmailTotal.mock.calls + .length, + ).toEqual(expectedCount); + } + + it('should call "queueUpdateAssociatedCasesWorker" with user information and wait until all expected cases to update', async () => { + const TEST_DOCKER_NUMBERS = ['TEST_1', 'TEST_2', 'TEST_3', 'TEST_4']; + let COMPLETE_FLAG = false; + applicationContext + .getPersistenceGateway() + .getDocketNumbersByUser.mockReturnValue(TEST_DOCKER_NUMBERS); + + void queueEmailUpdateAssociatedCasesWorker( + applicationContext, + { user: TEST_USER }, + mockPetitionerUser, + ).then(() => { + const queueUpdateAssociatedCasesWorkerCalls = + applicationContext.getUseCases().queueUpdateAssociatedCasesWorker.mock + .calls; + + expect(queueUpdateAssociatedCasesWorkerCalls.length).toEqual(1); + expect(queueUpdateAssociatedCasesWorkerCalls[0][1]).toEqual({ + user: TEST_USER, + }); + expect(queueUpdateAssociatedCasesWorkerCalls[0][2]).toEqual( + mockPetitionerUser, + ); + + const updateUserCalls = + applicationContext.getPersistenceGateway().updateUser.mock.calls; + expect(updateUserCalls.length).toEqual(1); + expect(updateUserCalls[0][0].user).toMatchObject({ + isUpdatingInformation: false, + }); + + COMPLETE_FLAG = true; + }); + + await sleep(100); + assertFunctionCalls(1); + RESOLVER(0); + + await sleep(50); + assertFunctionCalls(2); + RESOLVER(2); + + await sleep(50); + assertFunctionCalls(3); + RESOLVER(TEST_DOCKER_NUMBERS.length); + + await sleep(50); + expect(COMPLETE_FLAG).toEqual(true); + }); + + it('should call resolve the interactor when the max number of iterations is met', async () => { + applicationContext + .getPersistenceGateway() + .getCasesByEmailTotal.mockImplementation(() => {}); + + const TEST_DOCKER_NUMBERS = ['TEST_1', 'TEST_2', 'TEST_3', 'TEST_4']; + let COMPLETE_FLAG = false; + applicationContext + .getPersistenceGateway() + .getDocketNumbersByUser.mockReturnValue(TEST_DOCKER_NUMBERS); + + void queueEmailUpdateAssociatedCasesWorker( + applicationContext, + { user: TEST_USER }, + mockPetitionerUser, + ).then(() => { + COMPLETE_FLAG = true; + }); + + await sleep(100); + expect(COMPLETE_FLAG).toEqual(true); + + const getCasesByEmailTotalCalls = + applicationContext.getPersistenceGateway().getCasesByEmailTotal.mock + .calls; + + expect(getCasesByEmailTotalCalls.length).toEqual(MAX_ITERATIONS + 1); + }); + + it('should resolve the interactor when the there is an error thrown in the check method', async () => { + applicationContext + .getPersistenceGateway() + .getCasesByEmailTotal.mockImplementation(() => { + throw Error('TEST ERROR'); + }); + + const TEST_DOCKER_NUMBERS = ['TEST_1', 'TEST_2', 'TEST_3', 'TEST_4']; + applicationContext + .getPersistenceGateway() + .getDocketNumbersByUser.mockReturnValue(TEST_DOCKER_NUMBERS); + + await queueEmailUpdateAssociatedCasesWorker( + applicationContext, + { user: TEST_USER }, + mockPetitionerUser, + ); + + const updateUserCalls = + applicationContext.getPersistenceGateway().updateUser.mock.calls; + expect(updateUserCalls.length).toEqual(1); + expect(updateUserCalls[0][0].user).toMatchObject({ + isUpdatingInformation: false, + }); + }); +}); diff --git a/web-api/src/business/useCases/user/queueEmailUpdateAssociatedCasesWorker.ts b/web-api/src/business/useCases/user/queueEmailUpdateAssociatedCasesWorker.ts new file mode 100644 index 00000000000..7c9f53cf845 --- /dev/null +++ b/web-api/src/business/useCases/user/queueEmailUpdateAssociatedCasesWorker.ts @@ -0,0 +1,102 @@ +import { AuthUser } from '@shared/business/entities/authUser/AuthUser'; +import { RawPractitioner } from '@shared/business/entities/Practitioner'; +import { RawUser } from '@shared/business/entities/User'; +import { ServerApplicationContext } from '@web-api/applicationContext'; +import { UserFactory } from '@shared/business/entities/factories/UserFactory'; + +async function disableIsUserUpdatingFlag({ + applicationContext, + user, +}: { + applicationContext: ServerApplicationContext; + user: RawUser | RawPractitioner; +}): Promise { + const userFactory = new UserFactory(user); + const UserClass = userFactory.getClass(); + + user.isUpdatingInformation = false; + const userEntity = new UserClass(user); + + await applicationContext.getPersistenceGateway().updateUser({ + applicationContext, + user: userEntity.validate().toRawObject(), + }); +} + +export const queueEmailUpdateAssociatedCasesWorker = async ( + applicationContext: ServerApplicationContext, + { user }: { user: RawUser | RawPractitioner }, + authorizedUser: AuthUser, +): Promise => { + const docketNumbersByUser = await applicationContext + .getPersistenceGateway() + .getDocketNumbersByUser({ + applicationContext, + userId: user.userId, + }); + + if (!docketNumbersByUser.length) { + await disableIsUserUpdatingFlag({ applicationContext, user }); + return; + } + + await applicationContext + .getUseCases() + .queueUpdateAssociatedCasesWorker( + applicationContext, + { user }, + authorizedUser, + ); + + await waitUntilAllExpectedCasesAreUpdatedWithEmail({ + applicationContext, + userEmail: user.email!, + }) + .catch(error => + console.error(`ERROR CHECKING COUNT OF UPDATED CASES -> ${error}`), + ) + .finally(async () => { + await disableIsUserUpdatingFlag({ applicationContext, user }); + }); +}; + +const WAIT_TIMEOUT = 2000; +const MAX_WAITTIME_IN_MINUTES = 14; +export const MAX_ITERATIONS = Math.floor( + (MAX_WAITTIME_IN_MINUTES * 60 * 1000) / WAIT_TIMEOUT, +); + +async function waitUntilAllExpectedCasesAreUpdatedWithEmail({ + applicationContext, + iteration = 0, + userEmail, +}: { + applicationContext: ServerApplicationContext; + iteration?: number; + userEmail: string; +}): Promise { + await applicationContext.getUtilities().sleep(WAIT_TIMEOUT); + + const docketNumbersByUser = await applicationContext + .getPersistenceGateway() + .getDocketNumbersByUser({ + applicationContext, + userId: userEmail, + }); + const expectedCount = docketNumbersByUser.length; + + const actualCount = await applicationContext + .getPersistenceGateway() + .getCasesByEmailTotal({ + applicationContext, + email: userEmail, + }); + + if (actualCount >= expectedCount) return; + if (iteration >= MAX_ITERATIONS) return; + return waitUntilAllExpectedCasesAreUpdatedWithEmail({ + applicationContext, + iteration: iteration + 1, + userEmail, + }); +} diff --git a/web-api/src/business/useCases/user/updateUserContactInformationInteractor.locking.test.ts b/web-api/src/business/useCases/user/updateUserContactInformationInteractor.locking.test.ts index 337e76034d4..e0ebab46a55 100644 --- a/web-api/src/business/useCases/user/updateUserContactInformationInteractor.locking.test.ts +++ b/web-api/src/business/useCases/user/updateUserContactInformationInteractor.locking.test.ts @@ -10,6 +10,7 @@ import { handleLockError, updateUserContactInformationInteractor, } from './updateUserContactInformationInteractor'; +import { sleep } from '@shared/tools/helpers'; const contactInfo = { address1: '234 Main St', @@ -37,6 +38,10 @@ describe('determineEntitiesToLock', () => { applicationContext .getPersistenceGateway() .getCasesForUser.mockReturnValue(mockCases); + + applicationContext + .getPersistenceGateway() + .getUserByIdOnceAllUpdatesComplete.mockResolvedValue(null); }); it('should lookup the docket numbers for the specified user', async () => { @@ -58,6 +63,34 @@ describe('determineEntitiesToLock', () => { expect(identifiers).toContain(`case|${mockCase.docketNumber}`); }); }); + + it('should wait until user is free before calling getCasesForUser', async () => { + let resolver: Function; + + applicationContext + .getPersistenceGateway() + .getUserByIdOnceAllUpdatesComplete.mockImplementation(() => { + return new Promise(resolve => (resolver = resolve)); + }); + + void determineEntitiesToLock(applicationContext, mockParams); + + await sleep(50); + expect( + applicationContext.getPersistenceGateway().getCasesForUser, + ).not.toHaveBeenCalled(); + + await sleep(50); + expect( + applicationContext.getPersistenceGateway().getCasesForUser, + ).not.toHaveBeenCalled(); + + resolver!(null); + await sleep(50); + expect( + applicationContext.getPersistenceGateway().getCasesForUser, + ).toHaveBeenCalled(); + }); }); describe('handleLockError', () => { @@ -107,6 +140,10 @@ describe('updateUserContactInformationInteractor', () => { entityName: 'Practitioner', }); + applicationContext + .getPersistenceGateway() + .getUserByIdOnceAllUpdatesComplete.mockResolvedValue(null); + applicationContext .getPersistenceGateway() .getCaseByDocketNumber.mockReturnValue(MOCK_CASE); diff --git a/web-api/src/business/useCases/user/updateUserContactInformationInteractor.ts b/web-api/src/business/useCases/user/updateUserContactInformationInteractor.ts index 0994799fb13..ddb07cd9b44 100644 --- a/web-api/src/business/useCases/user/updateUserContactInformationInteractor.ts +++ b/web-api/src/business/useCases/user/updateUserContactInformationInteractor.ts @@ -112,7 +112,7 @@ const updateUserContactInformationHelper = async ( }); if (isArray(results) && !results.length) { - userEntity.setIsUpdatingInformation(undefined); + userEntity.setIsUpdatingInformation(false); await applicationContext.getPersistenceGateway().updateUser({ applicationContext, user: userEntity.validate().toRawObject(), @@ -198,6 +198,13 @@ export const determineEntitiesToLock = async ( applicationContext: ServerApplicationContext, { userId }: { userId: string }, ) => { + await applicationContext + .getPersistenceGateway() + .getUserByIdOnceAllUpdatesComplete({ + applicationContext, + userId, + }); + const cases = await applicationContext .getPersistenceGateway() .getCasesForUser({ diff --git a/web-api/src/business/useCases/user/verifyUserPendingEmailInteractor.test.ts b/web-api/src/business/useCases/user/verifyUserPendingEmailInteractor.test.ts index e9e30e6b206..333dcff035f 100644 --- a/web-api/src/business/useCases/user/verifyUserPendingEmailInteractor.test.ts +++ b/web-api/src/business/useCases/user/verifyUserPendingEmailInteractor.test.ts @@ -18,6 +18,7 @@ import { mockPetitionsClerkUser, mockPrivatePractitionerUser, } from '@shared/test/mockAuthUsers'; +import { sleep } from '@shared/tools/helpers'; import { validUser } from '../../../../../shared/src/test/mockUsers'; describe('Verify User Pending Email', () => { @@ -35,6 +36,20 @@ describe('Verify User Pending Email', () => { .minus({ hours: TOKEN_EXPIRATION_TIME_HOURS + 0.001 }) .toISO()!; + beforeEach(() => { + const TOTAL_CASE_COUNT = 100; + + applicationContext + .getPersistenceGateway() + .getDocketNumbersByUser.mockResolvedValue( + Array(TOTAL_CASE_COUNT).fill(undefined), + ); + + applicationContext + .getPersistenceGateway() + .getCasesByEmailTotal.mockReturnValue(TOTAL_CASE_COUNT); + }); + describe('userTokenHasExpired', () => { it('should return true when no token', () => { expect(userTokenHasExpired(undefined)).toBe(true); @@ -99,7 +114,7 @@ describe('Verify User Pending Email', () => { beforeEach(() => { applicationContext .getPersistenceGateway() - .getUserById.mockReturnValue(mockPractitioner); + .getUserByIdOnceAllUpdatesComplete.mockReturnValue(mockPractitioner); applicationContext .getPersistenceGateway() @@ -108,10 +123,6 @@ describe('Verify User Pending Email', () => { applicationContext .getPersistenceGateway() .getCaseByDocketNumber.mockReturnValue(mockCase); - - applicationContext - .getPersistenceGateway() - .getDocketNumbersByUser.mockReturnValue([mockCase.docketNumber]); }); it('should throw unauthorized error when user does not have permission to verify emails', async () => { @@ -139,10 +150,12 @@ describe('Verify User Pending Email', () => { }); it('should throw an unauthorized error when the token passed as an argument and the token store on the user are both undefined', async () => { - applicationContext.getPersistenceGateway().getUserById.mockReturnValue({ - ...mockPractitioner, - pendingEmailVerificationToken: undefined, - }); + applicationContext + .getPersistenceGateway() + .getUserByIdOnceAllUpdatesComplete.mockReturnValue({ + ...mockPractitioner, + pendingEmailVerificationToken: undefined, + }); await expect( verifyUserPendingEmailInteractor( @@ -156,10 +169,12 @@ describe('Verify User Pending Email', () => { }); it('should throw an unauthorized error when there is no token timestamp', async () => { - applicationContext.getPersistenceGateway().getUserById.mockReturnValue({ - ...mockPractitioner, - pendingEmailVerificationTokenTimestamp: undefined, - }); + applicationContext + .getPersistenceGateway() + .getUserByIdOnceAllUpdatesComplete.mockReturnValue({ + ...mockPractitioner, + pendingEmailVerificationTokenTimestamp: undefined, + }); await expect( verifyUserPendingEmailInteractor( @@ -173,10 +188,12 @@ describe('Verify User Pending Email', () => { }); it('should throw an unauthorized error when token timestamp is expired', async () => { - applicationContext.getPersistenceGateway().getUserById.mockReturnValue({ - ...mockPractitioner, - pendingEmailVerificationTokenTimestamp: TOKEN_TIMESTAMP_EXPIRED, - }); + applicationContext + .getPersistenceGateway() + .getUserByIdOnceAllUpdatesComplete.mockReturnValue({ + ...mockPractitioner, + pendingEmailVerificationTokenTimestamp: TOKEN_TIMESTAMP_EXPIRED, + }); await expect( verifyUserPendingEmailInteractor( @@ -269,7 +286,7 @@ describe('Verify User Pending Email', () => { it('should call updateUser with email set to pendingEmail and pending fields set to undefined', async () => { applicationContext .getPersistenceGateway() - .getUserById.mockReturnValue(mockPetitioner); + .getUserByIdOnceAllUpdatesComplete.mockReturnValue(mockPetitioner); await verifyUserPendingEmailInteractor( applicationContext, @@ -290,4 +307,37 @@ describe('Verify User Pending Email', () => { }); }); }); + + describe('verifyUserPendingEmailInteractor - Wait until User is free', () => { + it('should wait until the user record is free to run the interactor', async () => { + let resolver: Function; + let errorMessage: string; + + applicationContext + .getPersistenceGateway() + .getUserByIdOnceAllUpdatesComplete.mockImplementation(() => { + return new Promise(resolve => (resolver = resolve)); + }); + + verifyUserPendingEmailInteractor( + applicationContext, + { + token: 'abc', + }, + mockPrivatePractitionerUser, + ).catch(error => { + errorMessage = error.message; + }); + + await sleep(50); + expect(errorMessage!).toEqual(undefined); + + await sleep(50); + expect(errorMessage!).toEqual(undefined); + + resolver!(mockPrivatePractitionerUser); + await sleep(50); + expect(errorMessage!).toEqual('Tokens do not match'); + }); + }); }); diff --git a/web-api/src/business/useCases/user/verifyUserPendingEmailInteractor.ts b/web-api/src/business/useCases/user/verifyUserPendingEmailInteractor.ts index 6f9d43e2462..c182cb14482 100644 --- a/web-api/src/business/useCases/user/verifyUserPendingEmailInteractor.ts +++ b/web-api/src/business/useCases/user/verifyUserPendingEmailInteractor.ts @@ -4,7 +4,7 @@ import { isAuthorized, } from '../../../../../shared/src/authorization/authorizationClientService'; import { ServerApplicationContext } from '@web-api/applicationContext'; -import { UnauthorizedError } from '../../../errors/errors'; +import { UnauthorizedError } from '@web-api/errors/errors'; import { UnknownAuthUser } from '@shared/business/entities/authUser/AuthUser'; import { calculateDifferenceInHours, @@ -39,7 +39,10 @@ export const verifyUserPendingEmailInteractor = async ( const user = await applicationContext .getPersistenceGateway() - .getUserById({ applicationContext, userId: authorizedUser.userId }); + .getUserByIdOnceAllUpdatesComplete({ + applicationContext, + userId: authorizedUser.userId, + }); if ( !user.pendingEmailVerificationToken || @@ -73,6 +76,7 @@ export const verifyUserPendingEmailInteractor = async ( const { updatedUser } = await updateUserPendingEmailRecord( applicationContext, { + setIsUpdatingInformation: true, user, }, ); @@ -81,14 +85,14 @@ export const verifyUserPendingEmailInteractor = async ( attributesToUpdate: { email: updatedUser.email, }, - email: user.email, + email: user.email!, }); await applicationContext.getWorkerGateway().queueWork(applicationContext, { message: { authorizedUser, payload: { user: updatedUser }, - type: MESSAGE_TYPES.QUEUE_UPDATE_ASSOCIATED_CASES, + type: MESSAGE_TYPES.QUEUE_EMAIL_UPDATE_ASSOCIATED_CASES, }, }); }; diff --git a/web-api/src/database-types.ts b/web-api/src/database-types.ts index c1bee19ec32..f7cbb2296b4 100644 --- a/web-api/src/database-types.ts +++ b/web-api/src/database-types.ts @@ -1,6 +1,7 @@ import { ColumnType, Insertable, Selectable, Updateable } from 'kysely'; export interface Database { + dwUserCaseNote: UserCaseNoteTable; dwMessage: MessageTable; dwCase: CaseTable; } @@ -47,3 +48,13 @@ export interface CaseTable { export type CaseKysely = Selectable; export type NewCaseKysely = Insertable; export type UpdateCaseKysely = Updateable; + +export interface UserCaseNoteTable { + docketNumber: string; + userId: string; + notes?: string; +} + +export type UserCaseNoteKysely = Selectable; +export type NewUserCaseNoteKysely = Insertable; +export type UpdateUserCaseNoteKysely = Updateable; diff --git a/web-api/src/gateways/worker/workerRouter.test.ts b/web-api/src/gateways/worker/workerRouter.test.ts index 29f5b13727a..36b477fcd49 100644 --- a/web-api/src/gateways/worker/workerRouter.test.ts +++ b/web-api/src/gateways/worker/workerRouter.test.ts @@ -51,6 +51,28 @@ describe('workerRouter', () => { ); }); + it('should make a call to queue a user`s email associated cases for update when the message type is QUEUE_EMAIL_UPDATE_ASSOCIATED_CASES', async () => { + const mockMessage: WorkerMessage = { + authorizedUser: mockDocketClerkUser, + payload: { + abc: '123', + }, + type: MESSAGE_TYPES.QUEUE_EMAIL_UPDATE_ASSOCIATED_CASES, + }; + + await workerRouter(applicationContext, { + message: mockMessage, + }); + + expect( + applicationContext.getUseCases().queueEmailUpdateAssociatedCasesWorker, + ).toHaveBeenCalledWith( + applicationContext, + mockMessage.payload, + mockMessage.authorizedUser, + ); + }); + it('should throw an error when the message type provided was not recognized by the router', async () => { const mockMessage: WorkerMessage = { authorizedUser: mockDocketClerkUser, diff --git a/web-api/src/gateways/worker/workerRouter.ts b/web-api/src/gateways/worker/workerRouter.ts index c61b7316710..bad1f9c34d5 100644 --- a/web-api/src/gateways/worker/workerRouter.ts +++ b/web-api/src/gateways/worker/workerRouter.ts @@ -8,6 +8,7 @@ export type WorkerMessage = { }; export const MESSAGE_TYPES = { + QUEUE_EMAIL_UPDATE_ASSOCIATED_CASES: 'QUEUE_EMAIL_UPDATE_ASSOCIATED_CASES', QUEUE_UPDATE_ASSOCIATED_CASES: 'QUEUE_UPDATE_ASSOCIATED_CASES', UPDATE_ASSOCIATED_CASE: 'UPDATE_ASSOCIATED_CASE', } as const; @@ -42,6 +43,15 @@ export const workerRouter = async ( message.authorizedUser, ); break; + case MESSAGE_TYPES.QUEUE_EMAIL_UPDATE_ASSOCIATED_CASES: + await applicationContext + .getUseCases() + .queueEmailUpdateAssociatedCasesWorker( + applicationContext, + message.payload, + message.authorizedUser, + ); + break; default: throw new Error( `No matching router found for message: ${JSON.stringify(message)}`, diff --git a/web-api/src/getPersistenceGateway.ts b/web-api/src/getPersistenceGateway.ts index 3dd1b12b261..bea4f8ff3df 100644 --- a/web-api/src/getPersistenceGateway.ts +++ b/web-api/src/getPersistenceGateway.ts @@ -42,7 +42,6 @@ import { deletePractitionerDocument } from './persistence/dynamo/practitioners/d import { deleteRecord } from './persistence/elasticsearch/deleteRecord'; import { deleteTrialSession } from './persistence/dynamo/trialSessions/deleteTrialSession'; import { deleteTrialSessionWorkingCopy } from './persistence/dynamo/trialSessions/deleteTrialSessionWorkingCopy'; -import { deleteUserCaseNote } from './persistence/dynamo/userCaseNotes/deleteUserCaseNote'; import { deleteUserConnection } from './persistence/dynamo/notifications/deleteUserConnection'; import { deleteUserFromCase } from './persistence/dynamo/cases/deleteUserFromCase'; import { deleteWorkItem } from './persistence/dynamo/workitems/deleteWorkItem'; @@ -65,6 +64,7 @@ import { getCaseMetadataByDocketNumber } from './persistence/dynamo/cases/getCas import { getCaseMetadataWithCounsel } from './persistence/dynamo/cases/getCaseMetadataWithCounsel'; import { getCaseWorksheetsByDocketNumber } from '@web-api/persistence/dynamo/caseWorksheet/getCaseWorksheetsByDocketNumber'; import { getCasesByDocketNumbers } from './persistence/dynamo/cases/getCasesByDocketNumbers'; +import { getCasesByEmailTotal } from '@web-api/persistence/elasticsearch/getCasesByEmailTotal'; import { getCasesByLeadDocketNumber } from './persistence/dynamo/cases/getCasesByLeadDocketNumber'; import { getCasesByUserId } from './persistence/elasticsearch/getCasesByUserId'; import { getCasesClosedCountByJudge } from './persistence/elasticsearch/getCasesClosedCountByJudge'; @@ -116,8 +116,9 @@ import { getTrialSessions } from './persistence/dynamo/trialSessions/getTrialSes import { getUploadPolicy } from './persistence/s3/getUploadPolicy'; import { getUserByEmail } from './persistence/dynamo/users/getUserByEmail'; import { getUserById } from './persistence/dynamo/users/getUserById'; -import { getUserCaseNote } from './persistence/dynamo/userCaseNotes/getUserCaseNote'; -import { getUserCaseNoteForCases } from './persistence/dynamo/userCaseNotes/getUserCaseNoteForCases'; +import { getUserByIdOnceAllUpdatesComplete } from '@web-api/persistence/dynamo/users/getUserByIdOnceAllUpdatesComplete'; +import { getUserCaseNote } from '@web-api/persistence/postgres/userCaseNotes/getUserCaseNote'; +import { getUserCaseNoteForCases } from '@web-api/persistence/postgres/userCaseNotes/getUserCaseNoteForCases'; import { getUsersById } from './persistence/dynamo/users/getUsersById'; import { getUsersBySearchKey } from './persistence/dynamo/users/getUsersBySearchKey'; import { getUsersInSection } from './persistence/dynamo/users/getUsersInSection'; @@ -164,7 +165,6 @@ import { updatePractitionerUser } from './persistence/dynamo/users/updatePractit import { updateTrialSession } from './persistence/dynamo/trialSessions/updateTrialSession'; import { updateTrialSessionWorkingCopy } from './persistence/dynamo/trialSessions/updateTrialSessionWorkingCopy'; import { updateUser } from './persistence/dynamo/users/updateUser'; -import { updateUserCaseNote } from './persistence/dynamo/userCaseNotes/updateUserCaseNote'; import { updateUserRecords } from './persistence/dynamo/users/updateUserRecords'; import { uploadDocument } from '@web-api/persistence/s3/uploadDocument'; import { verifyCaseForUser } from './persistence/dynamo/cases/verifyCaseForUser'; @@ -257,7 +257,6 @@ const gatewayMethods = { updateTrialSession, updateTrialSessionWorkingCopy, updateUser, - updateUserCaseNote, updateUserRecords, }), // methods below are not known to create or update "entity" records @@ -277,7 +276,6 @@ const gatewayMethods = { deleteRecord, deleteTrialSession, deleteTrialSessionWorkingCopy, - deleteUserCaseNote, deleteUserConnection, deleteUserFromCase, deleteWorkItem, @@ -298,6 +296,7 @@ const gatewayMethods = { getCaseMetadataWithCounsel, getCaseWorksheetsByDocketNumber, getCasesByDocketNumbers, + getCasesByEmailTotal, getCasesByLeadDocketNumber, getCasesByUserId, getCasesClosedCountByJudge, @@ -349,6 +348,7 @@ const gatewayMethods = { getUploadPolicy, getUserByEmail, getUserById, + getUserByIdOnceAllUpdatesComplete, getUserCaseNote, getUserCaseNoteForCases, getUsersById, diff --git a/web-api/src/getUseCases.ts b/web-api/src/getUseCases.ts index 02b53987ee4..72b03b32784 100644 --- a/web-api/src/getUseCases.ts +++ b/web-api/src/getUseCases.ts @@ -149,6 +149,7 @@ import { orderAdvancedSearchInteractor } from '../../shared/src/business/useCase import { orderPublicSearchInteractor } from './business/useCases/public/orderPublicSearchInteractor'; import { prioritizeCaseInteractor } from '../../shared/src/business/useCases/prioritizeCaseInteractor'; import { processStreamRecordsInteractor } from './business/useCases/processStreamRecords/processStreamRecordsInteractor'; +import { queueEmailUpdateAssociatedCasesWorker } from '@web-api/business/useCases/user/queueEmailUpdateAssociatedCasesWorker'; import { queueUpdateAssociatedCasesWorker } from './business/useCases/user/queueUpdateAssociatedCasesWorker'; import { removeCaseFromTrialInteractor } from './business/useCases/trialSessions/removeCaseFromTrialInteractor'; import { removeCasePendingItemInteractor } from '../../shared/src/business/useCases/removeCasePendingItemInteractor'; @@ -363,6 +364,7 @@ const useCases = { orderPublicSearchInteractor, prioritizeCaseInteractor, processStreamRecordsInteractor, + queueEmailUpdateAssociatedCasesWorker, queueUpdateAssociatedCasesWorker, removeCaseFromTrialInteractor, removeCasePendingItemInteractor, diff --git a/web-api/src/logger.test.ts b/web-api/src/logger.test.ts index ac23105cdbd..9df943eb3e6 100644 --- a/web-api/src/logger.test.ts +++ b/web-api/src/logger.test.ts @@ -61,11 +61,12 @@ describe('logger', () => { const instance = req.locals.logger; instance.info = jest.fn(); + jest.spyOn(instance, 'addContext'); res.end(); - expect(instance.info).toHaveBeenCalledWith( - expect.any(String), + expect(instance.info).toHaveBeenCalledWith(expect.any(String)); + expect(instance.addContext).toHaveBeenCalledWith( expect.objectContaining({ response: expect.objectContaining({ statusCode: 200, diff --git a/web-api/src/logger.ts b/web-api/src/logger.ts index 2bc1e530acd..7843e1a4ab3 100644 --- a/web-api/src/logger.ts +++ b/web-api/src/logger.ts @@ -43,13 +43,15 @@ export const expressLogger = (req, res, next) => { end.apply(this, arguments); const responseTimeMs = new Date() - req.locals.startTime; - logger.info(`Request ended: ${req.method} ${req.url}`, { + logger.addContext({ response: { responseSize: parseInt(res.get('content-length') ?? '0'), responseTimeMs, statusCode: res.statusCode, }, }); + + logger.info(`Request ended: ${req.method} ${req.url}`); logger.clearContext(); }; diff --git a/web-api/src/persistence/dynamo/userCaseNotes/deleteUserCaseNote.test.ts b/web-api/src/persistence/dynamo/userCaseNotes/deleteUserCaseNote.test.ts deleted file mode 100644 index 3a285c0efdf..00000000000 --- a/web-api/src/persistence/dynamo/userCaseNotes/deleteUserCaseNote.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { MOCK_CASE } from '../../../../../shared/src/test/mockCase'; -import { applicationContext } from '../../../../../shared/src/business/test/createTestApplicationContext'; -import { deleteUserCaseNote } from './deleteUserCaseNote'; -import { remove } from '../../dynamodbClientService'; - -jest.mock('../../dynamodbClientService', () => ({ - remove: jest.fn(), -})); - -describe('deleteUserCaseNote', () => { - const USER_ID = '10ecc428-ca35-4e36-aef2-e844660ce22d'; - - it('attempts to delete the case note', async () => { - await deleteUserCaseNote({ - applicationContext, - docketNumber: MOCK_CASE.docketNumber, - userId: USER_ID, - }); - - expect((remove as jest.Mock).mock.calls[0][0]).toMatchObject({ - key: { - pk: `user-case-note|${MOCK_CASE.docketNumber}`, - sk: `user|${USER_ID}`, - }, - }); - }); -}); diff --git a/web-api/src/persistence/dynamo/userCaseNotes/deleteUserCaseNote.ts b/web-api/src/persistence/dynamo/userCaseNotes/deleteUserCaseNote.ts deleted file mode 100644 index 82c8d0397c4..00000000000 --- a/web-api/src/persistence/dynamo/userCaseNotes/deleteUserCaseNote.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { remove } from '../../dynamodbClientService'; - -/** - * deleteUserCaseNote - * - * @param {object} providers the providers object - * @param {object} providers.applicationContext the application context - * @param {string} providers.docketNumber the docket number of the case the notes are associated with - * @param {string} providers.userId the id of the user who owns the case notes - * @returns {Array} the promises for the persistence delete calls - */ -export const deleteUserCaseNote = ({ - applicationContext, - docketNumber, - userId, -}: { - applicationContext: IApplicationContext; - docketNumber: string; - userId: string; -}) => - remove({ - applicationContext, - key: { - pk: `user-case-note|${docketNumber}`, - sk: `user|${userId}`, - }, - }); diff --git a/web-api/src/persistence/dynamo/userCaseNotes/getUserCaseNote.test.ts b/web-api/src/persistence/dynamo/userCaseNotes/getUserCaseNote.test.ts deleted file mode 100644 index 566e7849351..00000000000 --- a/web-api/src/persistence/dynamo/userCaseNotes/getUserCaseNote.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { MOCK_CASE } from '../../../../../shared/src/test/mockCase'; -import { applicationContext } from '../../../../../shared/src/business/test/createTestApplicationContext'; -import { get } from '../../dynamodbClientService'; -import { getUserCaseNote } from './getUserCaseNote'; - -const USER_ID = '220b5dc9-9d0c-4662-97ad-2cb9729c611a'; - -jest.mock('../../dynamodbClientService', () => ({ - get: jest.fn().mockReturnValue({ - notes: 'something', - pk: `user-case-note|${MOCK_CASE.docketNumber}`, - sk: `user|${USER_ID}`, - userId: USER_ID, - }), -})); - -describe('getUserCaseNote', () => { - it('should get the case notes using docket number and user id', async () => { - const result = await getUserCaseNote({ - applicationContext, - docketNumber: MOCK_CASE.docketNumber, - userId: USER_ID, - }); - - expect((get as jest.Mock).mock.calls[0][0]).toMatchObject({ - Key: { - pk: `user-case-note|${MOCK_CASE.docketNumber}`, - sk: `user|${USER_ID}`, - }, - }); - - expect(result).toEqual({ - notes: 'something', - pk: `user-case-note|${MOCK_CASE.docketNumber}`, - sk: `user|${USER_ID}`, - userId: USER_ID, - }); - }); -}); diff --git a/web-api/src/persistence/dynamo/userCaseNotes/getUserCaseNote.ts b/web-api/src/persistence/dynamo/userCaseNotes/getUserCaseNote.ts deleted file mode 100644 index b7b9f4d0566..00000000000 --- a/web-api/src/persistence/dynamo/userCaseNotes/getUserCaseNote.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { get } from '../../dynamodbClientService'; - -/** - * getUserCaseNote - * - * @param {object} providers the providers object - * @param {object} providers.applicationContext the application context - * @param {string} providers.docketNumber the docket number of the case to get the case notes for - * @param {string} providers.userId the id of the user to get the case notes for - * @returns {Promise} the promise of the persistence call to get the record - */ -export const getUserCaseNote = ({ - applicationContext, - docketNumber, - userId, -}: { - applicationContext: IApplicationContext; - docketNumber: string; - userId: string; -}) => - get({ - Key: { - pk: `user-case-note|${docketNumber}`, - sk: `user|${userId}`, - }, - applicationContext, - }); diff --git a/web-api/src/persistence/dynamo/userCaseNotes/getUserCaseNoteForCases.test.ts b/web-api/src/persistence/dynamo/userCaseNotes/getUserCaseNoteForCases.test.ts deleted file mode 100644 index 7d2cdd56c07..00000000000 --- a/web-api/src/persistence/dynamo/userCaseNotes/getUserCaseNoteForCases.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { MOCK_CASE } from '../../../../../shared/src/test/mockCase'; -import { applicationContext } from '../../../../../shared/src/business/test/createTestApplicationContext'; -import { getUserCaseNoteForCases } from './getUserCaseNoteForCases'; - -const USER_ID = 'b1edae5a-23e4-4dc8-9d6c-43060ab3d8c7'; - -jest.mock('../../dynamodbClientService', () => ({ - batchGet: jest.fn().mockReturnValue([ - { - notes: 'something', - pk: `user-case-note|${MOCK_CASE.docketNumber}`, - sk: `user|${USER_ID}`, - userId: USER_ID, - }, - ]), -})); - -describe('getUserCaseNoteForCases', () => { - it('should get the case notes by case id and user id', async () => { - const result = await getUserCaseNoteForCases({ - applicationContext, - docketNumbers: [MOCK_CASE.docketNumber], - userId: USER_ID, - }); - - expect(result).toEqual([ - { - notes: 'something', - pk: `user-case-note|${MOCK_CASE.docketNumber}`, - sk: `user|${USER_ID}`, - userId: USER_ID, - }, - ]); - }); -}); diff --git a/web-api/src/persistence/dynamo/userCaseNotes/getUserCaseNoteForCases.ts b/web-api/src/persistence/dynamo/userCaseNotes/getUserCaseNoteForCases.ts deleted file mode 100644 index aea2d8f5f9c..00000000000 --- a/web-api/src/persistence/dynamo/userCaseNotes/getUserCaseNoteForCases.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { batchGet } from '../../dynamodbClientService'; - -/** - * getUserCaseNoteForCases - * - * @param {object} providers the providers object - * @param {object} providers.applicationContext the application context - * @param {Array} providers.docketNumbers the docket numbers of the cases to get the case notes for - * @param {string} providers.userId the id of the user to get the case notes for - * @returns {Promise} the promise of the persistence call to get the record - */ -export const getUserCaseNoteForCases = ({ - applicationContext, - docketNumbers, - userId, -}: { - applicationContext: IApplicationContext; - docketNumbers: string[]; - userId: string; -}) => - batchGet({ - applicationContext, - keys: docketNumbers.map(docketNumber => ({ - pk: `user-case-note|${docketNumber}`, - sk: `user|${userId}`, - })), - }); diff --git a/web-api/src/persistence/dynamo/userCaseNotes/updateUserCaseNote.test.ts b/web-api/src/persistence/dynamo/userCaseNotes/updateUserCaseNote.test.ts deleted file mode 100644 index 376b3ab973f..00000000000 --- a/web-api/src/persistence/dynamo/userCaseNotes/updateUserCaseNote.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { MOCK_CASE } from '../../../../../shared/src/test/mockCase'; -import { applicationContext } from '../../../../../shared/src/business/test/createTestApplicationContext'; -import { put } from '../../dynamodbClientService'; -import { updateUserCaseNote } from './updateUserCaseNote'; - -jest.mock('../../dynamodbClientService', () => ({ - put: jest.fn(), -})); - -describe('updateUserCaseNote', () => { - const USER_ID = '42f68c70-b803-4883-985d-ea1903e31ae2'; - - it('invokes the persistence layer with pk of user-case-note|{docketNumber}, sk of {userId} and other expected params', async () => { - await updateUserCaseNote({ - applicationContext, - caseNoteToUpdate: { - docketNumber: MOCK_CASE.docketNumber, - notes: 'something!!!', - userId: USER_ID, - }, - }); - - expect((put as jest.Mock).mock.calls[0][0]).toMatchObject({ - Item: { - notes: 'something!!!', - pk: `user-case-note|${MOCK_CASE.docketNumber}`, - sk: `user|${USER_ID}`, - }, - }); - }); -}); diff --git a/web-api/src/persistence/dynamo/userCaseNotes/updateUserCaseNote.ts b/web-api/src/persistence/dynamo/userCaseNotes/updateUserCaseNote.ts deleted file mode 100644 index ba08d161fbf..00000000000 --- a/web-api/src/persistence/dynamo/userCaseNotes/updateUserCaseNote.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { put } from '../../dynamodbClientService'; - -/** - * updateUserCaseNote - * - * @param {object} providers the providers object - * @param {object} providers.applicationContext the application context - * @param {object} providers.caseNoteToUpdate the case note data to update - * @returns {Promise} the promise of the call to persistence - */ -export const updateUserCaseNote = ({ - applicationContext, - caseNoteToUpdate, -}: { - applicationContext: IApplicationContext; - caseNoteToUpdate: TCaseNote; -}) => - put({ - Item: { - ...caseNoteToUpdate, - pk: `user-case-note|${caseNoteToUpdate.docketNumber}`, - sk: `user|${caseNoteToUpdate.userId}`, - }, - applicationContext, - }); diff --git a/web-api/src/persistence/dynamo/users/getUserByIdOnceAllUpdatesComplete.test.ts b/web-api/src/persistence/dynamo/users/getUserByIdOnceAllUpdatesComplete.test.ts new file mode 100644 index 00000000000..4138c6bd746 --- /dev/null +++ b/web-api/src/persistence/dynamo/users/getUserByIdOnceAllUpdatesComplete.test.ts @@ -0,0 +1,50 @@ +import { UserRecord } from '@web-api/persistence/dynamo/dynamoTypes'; +import { applicationContext } from '@shared/business/test/createTestApplicationContext'; +import { getUserByIdOnceAllUpdatesComplete } from '@web-api/persistence/dynamo/users/getUserByIdOnceAllUpdatesComplete'; +import { sleep } from '@shared/tools/helpers'; + +describe('getUserByIdOnceAllUpdatesComplete', () => { + const TEST_USER_ID = 'TEST_USER_ID'; + let RESOLVER: Function; + + beforeEach(() => { + applicationContext + .getPersistenceGateway() + .getUserById.mockImplementation( + () => new Promise(resolve => (RESOLVER = resolve)), + ); + }); + + it('should wait until the user is done updating to return the user record', async () => { + let COMPLETE_FLAG = false; + + void getUserByIdOnceAllUpdatesComplete({ + applicationContext, + userId: TEST_USER_ID, + }).then(userInfo => { + expect(userInfo).toEqual({ isUpdatingInformation: false }); + COMPLETE_FLAG = true; + }); + + let getUserByIdCalls = + applicationContext.getPersistenceGateway().getUserById.mock.calls; + expect(getUserByIdCalls.length).toEqual(1); + RESOLVER({ isUpdatingInformation: true } as UserRecord); + await sleep(50); + expect(COMPLETE_FLAG).toEqual(false); + + getUserByIdCalls = + applicationContext.getPersistenceGateway().getUserById.mock.calls; + expect(getUserByIdCalls.length).toEqual(2); + RESOLVER({ isUpdatingInformation: true } as UserRecord); + await sleep(50); + expect(COMPLETE_FLAG).toEqual(false); + + getUserByIdCalls = + applicationContext.getPersistenceGateway().getUserById.mock.calls; + expect(getUserByIdCalls.length).toEqual(3); + RESOLVER({ isUpdatingInformation: false } as UserRecord); + await sleep(50); + expect(COMPLETE_FLAG).toEqual(true); + }); +}); diff --git a/web-api/src/persistence/dynamo/users/getUserByIdOnceAllUpdatesComplete.ts b/web-api/src/persistence/dynamo/users/getUserByIdOnceAllUpdatesComplete.ts new file mode 100644 index 00000000000..f157e19db39 --- /dev/null +++ b/web-api/src/persistence/dynamo/users/getUserByIdOnceAllUpdatesComplete.ts @@ -0,0 +1,21 @@ +import { UserRecord } from '@web-api/persistence/dynamo/dynamoTypes'; + +export const getUserByIdOnceAllUpdatesComplete = async ({ + applicationContext, + userId, +}: { + applicationContext: IApplicationContext; + userId: string; +}): Promise => { + const user = await applicationContext + .getPersistenceGateway() + .getUserById({ applicationContext, userId }); + + if (!user.isUpdatingInformation) return user; + + await applicationContext.getUtilities().sleep(1500); + return await getUserByIdOnceAllUpdatesComplete({ + applicationContext, + userId, + }); +}; diff --git a/web-api/src/persistence/elasticsearch/caseAdvancedSearch.test.ts b/web-api/src/persistence/elasticsearch/caseAdvancedSearch.test.ts index a377a3f4639..a035815ef07 100644 --- a/web-api/src/persistence/elasticsearch/caseAdvancedSearch.test.ts +++ b/web-api/src/persistence/elasticsearch/caseAdvancedSearch.test.ts @@ -5,7 +5,10 @@ import { search } from './searchClient'; describe('caseAdvancedSearch', () => { it('returns results from an exact-matches query', async () => { - search.mockReturnValue({ results: ['some', 'matches'], total: 0 }); + (search as jest.Mock).mockReturnValue({ + results: ['some', 'matches'], + total: 0, + }); const results = await caseAdvancedSearch({ applicationContext, @@ -13,7 +16,9 @@ describe('caseAdvancedSearch', () => { }); expect(search).toHaveBeenCalledTimes(1); - expect(search.mock.calls[0][0].searchParameters.body['_source']).toEqual([ + expect( + (search as jest.Mock).mock.calls[0][0].searchParameters.body['_source'], + ).toEqual([ 'caseCaption', 'petitioners', 'docketNumber', @@ -29,7 +34,7 @@ describe('caseAdvancedSearch', () => { }); it('returns results from an non-exact-matches query when an exact query returns no results', async () => { - search + (search as jest.Mock) .mockImplementation(() => { // default behavior return Promise.resolve({ results: ['other', 'matches'], total: 2 }); diff --git a/web-api/src/persistence/elasticsearch/getCasesByEmailTotal.test.ts b/web-api/src/persistence/elasticsearch/getCasesByEmailTotal.test.ts new file mode 100644 index 00000000000..564a07dbf28 --- /dev/null +++ b/web-api/src/persistence/elasticsearch/getCasesByEmailTotal.test.ts @@ -0,0 +1,59 @@ +import { applicationContext } from '@shared/business/test/createTestApplicationContext'; +import { getCasesByEmailTotal } from '@web-api/persistence/elasticsearch/getCasesByEmailTotal'; +jest.mock('./searchClient'); +import { search } from './searchClient'; + +describe('getCasesByEmailTotal', () => { + const TEST_EMAIL = 'TEST_EMAIL'; + const RESULTS_MOCK = 99999; + + beforeEach(() => { + (search as jest.Mock).mockImplementation(() => ({ total: RESULTS_MOCK })); + }); + + it('should call search with correct parameters', async () => { + const results = await getCasesByEmailTotal({ + applicationContext, + email: TEST_EMAIL, + }); + + const searchCalls = (search as jest.Mock).mock.calls; + expect(searchCalls.length).toEqual(1); + expect(searchCalls[0][0].searchParameters).toEqual({ + body: { + query: { + bool: { + minimum_should_match: 1, + must: [ + { + term: { + 'entityName.S': 'Case', + }, + }, + ], + should: [ + { + term: { + 'privatePractitioners.L.M.email.S': TEST_EMAIL, + }, + }, + { + term: { + 'irsPractitioners.L.M.email.S': TEST_EMAIL, + }, + }, + { + term: { + 'petitioners.L.M.email.S': TEST_EMAIL, + }, + }, + ], + }, + }, + }, + index: 'efcms-case', + }); + + expect(results).toEqual(RESULTS_MOCK); + }); +}); diff --git a/web-api/src/persistence/elasticsearch/getCasesByEmailTotal.ts b/web-api/src/persistence/elasticsearch/getCasesByEmailTotal.ts new file mode 100644 index 00000000000..bd41e6b014f --- /dev/null +++ b/web-api/src/persistence/elasticsearch/getCasesByEmailTotal.ts @@ -0,0 +1,53 @@ +import { ServerApplicationContext } from '@web-api/applicationContext'; +import { search } from './searchClient'; + +type GetCasesByEmailParams = { + applicationContext: ServerApplicationContext; + email: string; +}; +export const getCasesByEmailTotal = async ({ + applicationContext, + email, +}: GetCasesByEmailParams) => { + const searchParameters = { + body: { + query: { + bool: { + minimum_should_match: 1, + must: [ + { + term: { + 'entityName.S': 'Case', + }, + }, + ], + should: [ + { + term: { + 'privatePractitioners.L.M.email.S': email, + }, + }, + { + term: { + 'irsPractitioners.L.M.email.S': email, + }, + }, + { + term: { + 'petitioners.L.M.email.S': email, + }, + }, + ], + }, + }, + }, + index: 'efcms-case', + }; + + const result = await search({ + applicationContext, + searchParameters, + }); + + return result.total; +}; diff --git a/web-api/src/persistence/postgres/userCaseNotes/deleteUserCaseNote.ts b/web-api/src/persistence/postgres/userCaseNotes/deleteUserCaseNote.ts new file mode 100644 index 00000000000..56af9163386 --- /dev/null +++ b/web-api/src/persistence/postgres/userCaseNotes/deleteUserCaseNote.ts @@ -0,0 +1,17 @@ +import { getDbWriter } from '@web-api/database'; + +export const deleteUserCaseNote = async ({ + docketNumber, + userId, +}: { + docketNumber: string; + userId: string; +}) => { + await getDbWriter(writer => + writer + .deleteFrom('dwUserCaseNote') + .where('docketNumber', '=', docketNumber) + .where('userId', '=', userId) + .execute(), + ); +}; diff --git a/web-api/src/persistence/postgres/userCaseNotes/getUserCaseNote.ts b/web-api/src/persistence/postgres/userCaseNotes/getUserCaseNote.ts new file mode 100644 index 00000000000..c6db8c43076 --- /dev/null +++ b/web-api/src/persistence/postgres/userCaseNotes/getUserCaseNote.ts @@ -0,0 +1,22 @@ +import { UserCaseNote } from '@shared/business/entities/notes/UserCaseNote'; +import { getDbReader } from '@web-api/database'; +import { transformNullToUndefined } from '@web-api/persistence/postgres/utils/transformNullToUndefined'; + +export const getUserCaseNote = async ({ + docketNumber, + userId, +}: { + docketNumber: string; + userId: string; +}) => { + const userCaseNote = await getDbReader(writer => + writer + .selectFrom('dwUserCaseNote') + .selectAll() + .where('docketNumber', '=', docketNumber) + .where('userId', '=', userId) + .executeTakeFirst(), + ); + + return new UserCaseNote(transformNullToUndefined(userCaseNote)); +}; diff --git a/web-api/src/persistence/postgres/userCaseNotes/getUserCaseNoteForCases.ts b/web-api/src/persistence/postgres/userCaseNotes/getUserCaseNoteForCases.ts new file mode 100644 index 00000000000..6e560147180 --- /dev/null +++ b/web-api/src/persistence/postgres/userCaseNotes/getUserCaseNoteForCases.ts @@ -0,0 +1,24 @@ +import { UserCaseNote } from '@shared/business/entities/notes/UserCaseNote'; +import { getDbReader } from '@web-api/database'; +import { transformNullToUndefined } from '@web-api/persistence/postgres/utils/transformNullToUndefined'; + +export const getUserCaseNoteForCases = async ({ + docketNumbers, + userId, +}: { + docketNumbers: string[]; + userId: string; +}) => { + const userCaseNotes = await getDbReader(reader => + reader + .selectFrom('dwUserCaseNote') + .selectAll() + .where('userId', '=', userId) + .where('docketNumber', 'in', docketNumbers) + .execute(), + ); + + return userCaseNotes.map( + userCaseNote => new UserCaseNote(transformNullToUndefined(userCaseNote)), + ); +}; diff --git a/web-api/src/persistence/postgres/userCaseNotes/mocks.jest.ts b/web-api/src/persistence/postgres/userCaseNotes/mocks.jest.ts new file mode 100644 index 00000000000..e13040c7cd9 --- /dev/null +++ b/web-api/src/persistence/postgres/userCaseNotes/mocks.jest.ts @@ -0,0 +1,26 @@ +import { mockFactory } from '@shared/test/mockFactory'; + +jest.mock( + '@web-api/persistence/postgres/userCaseNotes/deleteUserCaseNote.ts', + () => mockFactory('deleteUserCaseNote'), +); + +jest.mock( + '@web-api/persistence/postgres/userCaseNotes/getUserCaseNote.ts', + () => mockFactory('getUserCaseNote'), +); + +jest.mock( + '@web-api/persistence/postgres/userCaseNotes/getUserCaseNoteForCases.ts', + () => mockFactory('getUserCaseNoteForCases'), +); + +jest.mock( + '@web-api/persistence/postgres/userCaseNotes/upsertUserCaseNote.ts', + () => mockFactory('upsertUserCaseNote'), +); + +jest.mock( + '@web-api/persistence/postgres/userCaseNotes/upsertUserCaseNotes.ts', + () => mockFactory('upsertUserCaseNotes'), +); diff --git a/web-api/src/persistence/postgres/userCaseNotes/upsertUserCaseNote.ts b/web-api/src/persistence/postgres/userCaseNotes/upsertUserCaseNote.ts new file mode 100644 index 00000000000..a038d5615fb --- /dev/null +++ b/web-api/src/persistence/postgres/userCaseNotes/upsertUserCaseNote.ts @@ -0,0 +1,24 @@ +import { UserCaseNote } from '@shared/business/entities/notes/UserCaseNote'; +import { getDbWriter } from '@web-api/database'; + +export const upsertUserCaseNote = async ({ + caseNoteToUpsert, +}: { + caseNoteToUpsert: UserCaseNote; +}) => { + await getDbWriter(writer => + writer + .insertInto('dwUserCaseNote') + .values({ + docketNumber: caseNoteToUpsert.docketNumber, + notes: caseNoteToUpsert.notes, + userId: caseNoteToUpsert.userId, + }) + .onConflict(oc => + oc.columns(['docketNumber', 'userId']).doUpdateSet({ + notes: caseNoteToUpsert.notes, + }), + ) + .execute(), + ); +}; diff --git a/web-api/src/persistence/postgres/userCaseNotes/upsertUserCaseNotes.ts b/web-api/src/persistence/postgres/userCaseNotes/upsertUserCaseNotes.ts new file mode 100644 index 00000000000..dd951929521 --- /dev/null +++ b/web-api/src/persistence/postgres/userCaseNotes/upsertUserCaseNotes.ts @@ -0,0 +1,26 @@ +import { RawUserCaseNote } from '@shared/business/entities/notes/UserCaseNote'; +import { getDbWriter } from '@web-api/database'; + +export const upsertUserCaseNotes = async (userCaseNotes: RawUserCaseNote[]) => { + if (userCaseNotes.length === 0) return; + + const userCaseNotesToUpsert = userCaseNotes.map(rawUserCaseNote => ({ + docketNumber: rawUserCaseNote.docketNumber, + notes: rawUserCaseNote.notes, + userId: rawUserCaseNote.userId, + })); + + await getDbWriter(writer => + writer + .insertInto('dwUserCaseNote') + .values(userCaseNotesToUpsert) + .onConflict(oc => + oc.columns(['docketNumber', 'userId']).doUpdateSet(c => { + return { + notes: c.ref('excluded.notes'), + }; + }), + ) + .execute(), + ); +}; diff --git a/web-api/src/persistence/postgres/utils/migrate/migrations/0003-add-user-case-notes.ts b/web-api/src/persistence/postgres/utils/migrate/migrations/0003-add-user-case-notes.ts new file mode 100644 index 00000000000..6d21457ecbf --- /dev/null +++ b/web-api/src/persistence/postgres/utils/migrate/migrations/0003-add-user-case-notes.ts @@ -0,0 +1,15 @@ +import { Kysely } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('dwUserCaseNote') + .addColumn('docketNumber', 'varchar', col => col.notNull()) + .addColumn('userId', 'varchar', col => col.notNull()) + .addColumn('notes', 'text') + .addPrimaryKeyConstraint('pk_user_case_note', ['docketNumber', 'userId']) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('dwUserCaseNote').execute(); +} diff --git a/web-api/storage/fixtures/seed/efcms-local.json b/web-api/storage/fixtures/seed/efcms-local.json index 44d0e2472b8..70fe522d409 100644 --- a/web-api/storage/fixtures/seed/efcms-local.json +++ b/web-api/storage/fixtures/seed/efcms-local.json @@ -58524,5 +58524,13 @@ "pk": "user|f0a1e52a-876f-4c03-853c-f66e407e5a1e", "userId": "f0a1e52a-876f-4c03-853c-f66e407e5a1e", "email": "trialclerk@example.com" + }, + { + "pk": "user-case-note|107-19", + "sk": "user|dabbad00-18d0-43ec-bafb-654e83405416", + "docketNumber": "107-19", + "entityName": "UserCaseNote", + "notes": "Test", + "userId": "dabbad00-18d0-43ec-bafb-654e83405416" } ] diff --git a/web-client/src/presenter/actions/setAlertInfoAction.test.ts b/web-client/src/presenter/actions/setAlertInfoAction.test.ts new file mode 100644 index 00000000000..0e5403d0f3e --- /dev/null +++ b/web-client/src/presenter/actions/setAlertInfoAction.test.ts @@ -0,0 +1,15 @@ +import { runAction } from '@web-client/presenter/test.cerebral'; +import { setAlertInfoAction } from '@web-client/presenter/actions/setAlertInfoAction'; + +describe('setAlertInfoAction', () => { + it('should set alertInfo in state correctly', async () => { + const TEST_ALERT_INFO = 'TEST_ALERT_INFO'; + const { state } = await runAction(setAlertInfoAction, { + props: { + alertInfo: TEST_ALERT_INFO, + }, + }); + + expect(state.alertInfo).toEqual(TEST_ALERT_INFO); + }); +}); diff --git a/web-client/src/presenter/actions/setAlertInfoAction.ts b/web-client/src/presenter/actions/setAlertInfoAction.ts new file mode 100644 index 00000000000..cf6e7885db5 --- /dev/null +++ b/web-client/src/presenter/actions/setAlertInfoAction.ts @@ -0,0 +1,8 @@ +import { state } from '@web-client/presenter/app.cerebral'; + +export const setAlertInfoAction = ({ + props, + store, +}: ActionProps<{ alertInfo: any }>) => { + store.set(state.alertInfo, props.alertInfo); +}; diff --git a/web-client/src/presenter/actions/setInitialVerifyAlertMessageAction.test.ts b/web-client/src/presenter/actions/setInitialVerifyAlertMessageAction.test.ts new file mode 100644 index 00000000000..538a687c79e --- /dev/null +++ b/web-client/src/presenter/actions/setInitialVerifyAlertMessageAction.test.ts @@ -0,0 +1,15 @@ +import { runAction } from '@web-client/presenter/test.cerebral'; +import { setInitialVerifyAlertMessageAction } from '@web-client/presenter/actions/setInitialVerifyAlertMessageAction'; + +describe('setInitialVerifyAlertMessageAction', () => { + it('should return the correct alertInfo data', async () => { + const { output } = await runAction(setInitialVerifyAlertMessageAction, {}); + + expect(output).toEqual({ + alertInfo: { + message: 'DAWSON is updating your email. Please wait.', + title: 'Updating email address', + }, + }); + }); +}); diff --git a/web-client/src/presenter/actions/setInitialVerifyAlertMessageAction.ts b/web-client/src/presenter/actions/setInitialVerifyAlertMessageAction.ts new file mode 100644 index 00000000000..75a5658d7b6 --- /dev/null +++ b/web-client/src/presenter/actions/setInitialVerifyAlertMessageAction.ts @@ -0,0 +1,8 @@ +export const setInitialVerifyAlertMessageAction = () => { + return { + alertInfo: { + message: 'DAWSON is updating your email. Please wait.', + title: 'Updating email address', + }, + }; +}; diff --git a/web-client/src/presenter/actions/verifyUserPendingEmailAction.test.tsx b/web-client/src/presenter/actions/verifyUserPendingEmailAction.test.tsx index a56ff71a1de..d91e5fc7853 100644 --- a/web-client/src/presenter/actions/verifyUserPendingEmailAction.test.tsx +++ b/web-client/src/presenter/actions/verifyUserPendingEmailAction.test.tsx @@ -1,3 +1,4 @@ +import { GatewayTimeoutError } from '@web-client/presenter/errors/GatewayTimeoutError'; import { TROUBLESHOOTING_INFO } from '@shared/business/entities/EntityConstants'; import { applicationContextForClient as applicationContext } from '@web-client/test/createClientTestApplicationContext'; import { presenter } from '../presenter-mock'; @@ -20,15 +21,20 @@ describe('verifyUserPendingEmailAction', () => { }); it('should return a success message when the user`s pending email is successfully verified', async () => { - await runAction(verifyUserPendingEmailAction, { + const { state } = await runAction(verifyUserPendingEmailAction, { modules: { presenter, }, props: { token: mockToken, }, + state: { + alertInfo: 'TEST_ALERT_INFO', + }, }); + expect(state.alertInfo).toBeUndefined(); + expect( applicationContext.getUseCases().verifyUserPendingEmailInteractor, ).toHaveBeenCalledWith(expect.anything(), { token: mockToken }); @@ -100,4 +106,33 @@ describe('verifyUserPendingEmailAction', () => { }, }); }); + + it('should return an error message when the request timed out', async () => { + applicationContext + .getUseCases() + .verifyUserPendingEmailInteractor.mockRejectedValue( + new GatewayTimeoutError(), + ); + + await runAction(verifyUserPendingEmailAction, { + modules: { + presenter, + }, + props: { + token: mockToken, + }, + }); + + expect(errorMock).toHaveBeenCalledWith({ + alertError: { + message: ( + <> + DAWSON is updating your other contact information. Please wait and + try to verify your email in a few minutes. + + ), + title: 'DAWSON can’t verify your email right now.', + }, + }); + }); }); diff --git a/web-client/src/presenter/actions/verifyUserPendingEmailAction.tsx b/web-client/src/presenter/actions/verifyUserPendingEmailAction.tsx index 666de86a738..9555a2398dd 100644 --- a/web-client/src/presenter/actions/verifyUserPendingEmailAction.tsx +++ b/web-client/src/presenter/actions/verifyUserPendingEmailAction.tsx @@ -1,4 +1,6 @@ +import { GatewayTimeoutErrorTitle } from '@web-client/presenter/errors/GatewayTimeoutError'; import { TROUBLESHOOTING_INFO } from '@shared/business/entities/EntityConstants'; +import { state } from '@web-client/presenter/app.cerebral'; import React from 'react'; const expiredTokenAlertError = { @@ -11,7 +13,17 @@ const expiredTokenAlertError = { title: 'Verification email link expired', }; -const genericAlertError = { +const requestTimedOutAlertError = { + message: ( + <> + DAWSON is updating your other contact information. Please wait and try to + verify your email in a few minutes. + + ), + title: 'DAWSON can’t verify your email right now.', +}; + +export const genericAlertError = { message: ( <> Your request cannot be completed. Please try to log in. If you’re still @@ -29,6 +41,7 @@ export const verifyUserPendingEmailAction = async ({ applicationContext, path, props, + store, }: ActionProps<{ token: string }>) => { const { token } = props; @@ -38,6 +51,7 @@ export const verifyUserPendingEmailAction = async ({ .verifyUserPendingEmailInteractor(applicationContext, { token, }); + store.unset(state.alertInfo); return path.success({ alertSuccess: { @@ -47,11 +61,18 @@ export const verifyUserPendingEmailAction = async ({ }, }); } catch (e: any) { + store.unset(state.alertInfo); if (e.message === 'Link has expired') { return path.error({ alertError: expiredTokenAlertError, }); } + if (e.title === GatewayTimeoutErrorTitle) { + return path.error({ + alertError: requestTimedOutAlertError, + }); + } + return path.error({ alertError: genericAlertError, }); diff --git a/web-client/src/presenter/errors/GatewayTimeoutError.ts b/web-client/src/presenter/errors/GatewayTimeoutError.ts index 81a44022747..d67e9c35f60 100644 --- a/web-client/src/presenter/errors/GatewayTimeoutError.ts +++ b/web-client/src/presenter/errors/GatewayTimeoutError.ts @@ -1,11 +1,13 @@ import { ActionError } from './ActionError'; +export const GatewayTimeoutErrorTitle = + 'The system is taking too long to respond'; export class GatewayTimeoutError extends ActionError { // HTTP 504 constructor() { const message = 'Try again.'; super(message); - this.title = 'The system is taking too long to respond'; + this.title = GatewayTimeoutErrorTitle; this.message = message; } } diff --git a/web-client/src/presenter/sequences/gotoVerifyEmailSequence.ts b/web-client/src/presenter/sequences/gotoVerifyEmailSequence.ts index 65ad6bd2409..4212094a083 100644 --- a/web-client/src/presenter/sequences/gotoVerifyEmailSequence.ts +++ b/web-client/src/presenter/sequences/gotoVerifyEmailSequence.ts @@ -1,10 +1,16 @@ import { clearUserAction } from '../actions/clearUserAction'; +import { gotoLoginSequence } from '@web-client/presenter/sequences/Login/gotoLoginSequence'; import { navigateToLoginAction } from '@web-client/presenter/actions/Login/navigateToLoginAction'; import { setAlertErrorAction } from '@web-client/presenter/actions/setAlertErrorAction'; +import { setAlertInfoAction } from '@web-client/presenter/actions/setAlertInfoAction'; import { setAlertSuccessAction } from '@web-client/presenter/actions/setAlertSuccessAction'; +import { setInitialVerifyAlertMessageAction } from '@web-client/presenter/actions/setInitialVerifyAlertMessageAction'; import { verifyUserPendingEmailAction } from '../actions/verifyUserPendingEmailAction'; export const gotoVerifyEmailSequence = [ + setInitialVerifyAlertMessageAction, + setAlertInfoAction, + gotoLoginSequence, verifyUserPendingEmailAction, { error: [setAlertErrorAction], diff --git a/web-client/src/views/Login/Login.tsx b/web-client/src/views/Login/Login.tsx index 6a45d00d17f..3f554f88d6b 100644 --- a/web-client/src/views/Login/Login.tsx +++ b/web-client/src/views/Login/Login.tsx @@ -1,5 +1,6 @@ import { Button } from '@web-client/ustc-ui/Button/Button'; import { ErrorNotification } from '@web-client/views/ErrorNotification'; +import { InfoNotificationComponent } from '@web-client/views/InfoNotification'; import { SuccessNotification } from '@web-client/views/SuccessNotification'; import { WarningNotification } from '@web-client/views/WarningNotification'; import { connect } from '@web-client/presenter/shared.cerebral'; @@ -8,6 +9,7 @@ import React from 'react'; export const Login = connect( { + alertInfo: state.alertInfo, navigateToCreatePetitionerAccountSequence: sequences.navigateToCreatePetitionerAccountSequence, navigateToForgotPasswordSequence: @@ -19,6 +21,7 @@ export const Login = connect( sequences.updateAuthenticationFormValueSequence, }, ({ + alertInfo, navigateToCreatePetitionerAccountSequence, navigateToForgotPasswordSequence, showPassword, @@ -33,6 +36,12 @@ export const Login = connect(
+ {alertInfo && ( + + )}