diff --git a/hrm-domain/hrm-service/package.json b/hrm-domain/hrm-service/package.json index f93bbf8e4..41c78a5a6 100644 --- a/hrm-domain/hrm-service/package.json +++ b/hrm-domain/hrm-service/package.json @@ -45,6 +45,7 @@ "@tech-matters/twilio-client": "^1.0.0", "@tech-matters/http": "^1.0.0", "@tech-matters/resources-service": "^1.0.0", + "@tech-matters/sql": "^1.0.0", "@tech-matters/twilio-worker-auth": "^1.0.0", "@tech-matters/types": "^1.0.0", "aws-sdk": "^2.1231.0", diff --git a/hrm-domain/hrm-service/service-tests/case-search.test.ts b/hrm-domain/hrm-service/service-tests/case-search.test.ts index 8ba4e60f0..85152bbbd 100644 --- a/hrm-domain/hrm-service/service-tests/case-search.test.ts +++ b/hrm-domain/hrm-service/service-tests/case-search.test.ts @@ -1284,6 +1284,20 @@ describe('/cases route', () => { await caseDb.deleteById(createdCase.id, accountSid); useOpenRules(); }); + + test('Should throw error promptly with malformed input', async () => { + const start = Date.now(); + const response = await request + .post(`${route}/search`) + .query({ limit: 20, offset: 0 }) + .set(headers) + .send({ + filters: { createdAt: { from: '${contactNumber}' } }, + contactNumber: "=')) and (select pg_sleep(5)) is null AND ((1=1) --", + }); + expect(response.ok).toBeFalsy(); + expect(Date.now() - start).toBeLessThan(2000); + }, 10000); }); }); }); diff --git a/hrm-domain/hrm-service/src/case/case-data-access.ts b/hrm-domain/hrm-service/src/case/case-data-access.ts index 6d5d4cac7..2747af492 100644 --- a/hrm-domain/hrm-service/src/case/case-data-access.ts +++ b/hrm-domain/hrm-service/src/case/case-data-access.ts @@ -17,11 +17,13 @@ import { db, pgp } from '../connection-pool'; import { getPaginationElements } from '../search'; import { updateByIdSql } from './sql/case-update-sql'; -import { OrderByColumnType, OrderByDirectionType, selectCaseSearch } from './sql/case-search-sql'; +import { OrderByColumnType, selectCaseSearch } from './sql/case-search-sql'; import { caseSectionUpsertSql, deleteMissingCaseSectionsSql } from './sql/case-sections-sql'; import { DELETE_BY_ID } from './sql/case-delete-sql'; import { selectSingleCaseByIdSql } from './sql/case-get-sql'; import { Contact } from '../contact/contact-data-access'; +import { parameterizedQuery, OrderByDirectionType } from '@tech-matters/sql'; +import { ParameterizedQuery } from 'pg-promise'; export type CaseRecordCommon = { info: any; @@ -114,11 +116,12 @@ export const create = async ( let inserted: CaseRecord = await transaction.one(statement); if ((caseSections ?? []).length) { const allSections = caseSections.map(s => ({ ...s, caseId: inserted.id, accountSid })); - const sectionStatement = `${caseSectionUpsertSql(allSections)};${selectSingleCaseByIdSql( - 'Cases', - )}`; + const queryValues = { accountSid, caseId: inserted.id }; - inserted = await transaction.one(sectionStatement, queryValues); + await transaction.none(caseSectionUpsertSql(allSections)); + inserted = await transaction.one( + parameterizedQuery(selectSingleCaseByIdSql('Cases'), queryValues), + ); } return inserted; @@ -133,7 +136,7 @@ export const getById = async ( return db.task(async connection => { const statement = selectSingleCaseByIdSql('Cases'); const queryValues = { accountSid, caseId }; - return connection.oneOrNone(statement, queryValues); + return connection.oneOrNone(parameterizedQuery(statement, queryValues)); }); }; @@ -159,7 +162,9 @@ export const search = async ( limit: limit, offset: offset, }; - const result: CaseWithCount[] = await connection.any(statement, queryValues); + const result: CaseWithCount[] = await connection.manyOrNone( + parameterizedQuery(statement, queryValues), + ); const totalCount: number = result.length ? result[0].totalCount : 0; return { rows: result, count: totalCount }; }); @@ -168,7 +173,7 @@ export const search = async ( }; export const deleteById = async (id, accountSid) => { - return db.oneOrNone(DELETE_BY_ID, [accountSid, id]); + return db.oneOrNone(new ParameterizedQuery({ text: DELETE_BY_ID, values: [accountSid, id] })); }; export const update = async ( @@ -177,6 +182,7 @@ export const update = async ( accountSid: string, ): Promise => { return db.tx(async transaction => { + const caseUpdateSqlStatements = []; const statementValues = { accountSid, caseId: id, @@ -196,8 +202,22 @@ export const update = async ( Object.assign(statementValues, values); await transaction.none(sql, statementValues); } - await transaction.none(updateByIdSql(caseRecordUpdates, accountSid, id), statementValues); - - return transaction.oneOrNone(selectSingleCaseByIdSql('Cases'), statementValues); + const caseUpdateQuery = updateByIdSql(caseRecordUpdates); + // If there are preceding statements, put them in a CTE so we can run a single prepared statement + const fullUpdateQuery = caseUpdateSqlStatements.length + ? `WITH + ${caseUpdateSqlStatements.map((statement, i) => `q${i} AS (${statement})`).join(`, + `)} + ${caseUpdateQuery}` + : caseUpdateQuery; + await transaction.none(parameterizedQuery(fullUpdateQuery, statementValues)); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + return transaction.oneOrNone( + parameterizedQuery(selectSingleCaseByIdSql('Cases'), { + accountSid, + caseId: id, + }), + ); }); }; diff --git a/hrm-domain/hrm-service/src/case/sql/case-search-sql.ts b/hrm-domain/hrm-service/src/case/sql/case-search-sql.ts index d48944ac6..2d324ac61 100644 --- a/hrm-domain/hrm-service/src/case/sql/case-search-sql.ts +++ b/hrm-domain/hrm-service/src/case/sql/case-search-sql.ts @@ -19,15 +19,8 @@ import { SELECT_CASE_SECTIONS } from './case-sections-sql'; import { CaseListFilters, DateExistsCondition, DateFilter } from '../case-data-access'; import { leftJoinCsamReportsOnFK } from '../../csam-report/sql/csam-report-get-sql'; import { leftJoinReferralsOnFK } from '../../referral/sql/referral-get-sql'; - -export const OrderByDirection = { - ascendingNullsLast: 'ASC NULLS LAST', - descendingNullsLast: 'DESC NULLS LAST', - ascending: 'ASC', - descending: 'DESC', -} as const; - -export type OrderByDirectionType = typeof OrderByDirection[keyof typeof OrderByDirection]; +import { OrderByDirection } from '@tech-matters/sql'; +import { OrderByDirectionType } from '@tech-matters/sql/dist/ordering'; export const OrderByColumn = { ID: 'id', @@ -95,8 +88,8 @@ const dateFilterCondition = ( if (filter.to || filter.from) { filter.to = filter.to ?? null; filter.from = filter.from ?? null; - return `(($<${filterName}.from> IS NULL OR ${field} >= $<${filterName}.from>::TIMESTAMP WITH TIME ZONE) - AND ($<${filterName}.to> IS NULL OR ${field} <= $<${filterName}.to>::TIMESTAMP WITH TIME ZONE) + return `(($<${filterName}.from>::TIMESTAMP WITH TIME ZONE IS NULL OR ${field} >= $<${filterName}.from>::TIMESTAMP WITH TIME ZONE) + AND ($<${filterName}.to>::TIMESTAMP WITH TIME ZONE IS NULL OR ${field} <= $<${filterName}.to>::TIMESTAMP WITH TIME ZONE) ${existsCondition ? ` AND ${existsCondition}` : ''})`; } return existsCondition; @@ -160,15 +153,15 @@ const nameAndPhoneNumberSearchSql = ( lastNameSources: string[], phoneNumberColumns: string[], ) => - `CASE WHEN $ IS NULL THEN TRUE + `CASE WHEN $::text IS NULL THEN TRUE ELSE ${firstNameSources.map(fns => `${fns} ILIKE $`).join('\n OR ')} END AND - CASE WHEN $ IS NULL THEN TRUE + CASE WHEN $::text IS NULL THEN TRUE ELSE ${lastNameSources.map(lns => `${lns} ILIKE $`).join('\n OR ')} END AND - CASE WHEN $ IS NULL THEN TRUE + CASE WHEN $::text IS NULL THEN TRUE ELSE ( ${phoneNumberColumns .map(pn => `regexp_replace(${pn}, '\\D', '', 'g') ILIKE $`) @@ -178,7 +171,7 @@ const nameAndPhoneNumberSearchSql = ( const SEARCH_WHERE_CLAUSE = `( -- search on childInformation of connectedContacts - ($ IS NULL AND $ IS NULL AND $ IS NULL) OR + ($::text IS NULL AND $::text IS NULL AND $::text IS NULL) OR EXISTS ( SELECT 1 FROM "Contacts" c WHERE c."caseId" = cases.id AND c."accountSid" = cases."accountSid" AND ( @@ -236,7 +229,7 @@ const SEARCH_WHERE_CLAUSE = `( ) ) AND ( - $ IS NULL OR + $::text IS NULL OR EXISTS ( SELECT 1 FROM "Contacts" c WHERE c."caseId" = cases.id AND c."accountSid" = cases."accountSid" AND c.number = $ ) @@ -277,7 +270,7 @@ export const selectCaseSearch = ( `WHERE (info IS NULL OR jsonb_typeof(info) = 'object') AND - $ IS NOT NULL AND cases."accountSid" = $`, + $::text IS NOT NULL AND cases."accountSid" = $`, SEARCH_WHERE_CLAUSE, filterSql(filters), ].filter(sql => sql).join(` diff --git a/hrm-domain/hrm-service/src/case/sql/case-update-sql.ts b/hrm-domain/hrm-service/src/case/sql/case-update-sql.ts index 9f996d491..f2ac47180 100644 --- a/hrm-domain/hrm-service/src/case/sql/case-update-sql.ts +++ b/hrm-domain/hrm-service/src/case/sql/case-update-sql.ts @@ -26,14 +26,7 @@ const updateCaseColumnSet = new pgp.helpers.ColumnSet( { table: 'Cases' }, ); -export const updateByIdSql = ( - updatedValues: Record, - accountSid: string, - caseId: string, -) => ` +export const updateByIdSql = (updatedValues: Record) => ` ${pgp.helpers.update(updatedValues, updateCaseColumnSet)} - ${pgp.as.format(`WHERE "Cases"."accountSid" = $ AND "Cases"."id" = $`, { - accountSid, - caseId, - })} + WHERE "Cases"."accountSid" = $ AND "Cases"."id" = $ `; diff --git a/hrm-domain/hrm-service/src/connection-pool.ts b/hrm-domain/hrm-service/src/connection-pool.ts index ff05857df..a43e6e22e 100644 --- a/hrm-domain/hrm-service/src/connection-pool.ts +++ b/hrm-domain/hrm-service/src/connection-pool.ts @@ -14,7 +14,7 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ -import pgPromise from 'pg-promise'; +import pgPromise, { ITask } from 'pg-promise'; import config from './config/db'; export const pgp = pgPromise({}); @@ -24,3 +24,13 @@ export const db = pgp( config.host }:${config.port}/${encodeURIComponent(config.database)}?&application_name=hrm-service`, ); + +export const txIfNotInOne = async ( + task: ITask | undefined, + work: (y: ITask) => Promise, +): Promise => { + if (task) { + return task.txIf(work); + } + return db.tx(work); +}; diff --git a/hrm-domain/hrm-service/src/contact-job/contact-job-data-access.ts b/hrm-domain/hrm-service/src/contact-job/contact-job-data-access.ts index 2008e7a53..853597fea 100644 --- a/hrm-domain/hrm-service/src/contact-job/contact-job-data-access.ts +++ b/hrm-domain/hrm-service/src/contact-job/contact-job-data-access.ts @@ -14,7 +14,7 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ -import { db, pgp } from '../connection-pool'; +import { db, pgp, txIfNotInOne } from '../connection-pool'; // eslint-disable-next-line prettier/prettier import type { Contact } from '../contact/contact-data-access'; import { @@ -29,7 +29,6 @@ import { UPDATE_JOB_CLEANUP_ACTIVE_SQL, UPDATE_JOB_CLEANUP_PENDING_SQL, } from './sql/contact-job-sql'; -import { txIfNotInOne } from '../sql'; import { ContactJobType } from '@tech-matters/types'; diff --git a/hrm-domain/hrm-service/src/contact/contact-data-access.ts b/hrm-domain/hrm-service/src/contact/contact-data-access.ts index 2708e44b2..fd7c7355b 100644 --- a/hrm-domain/hrm-service/src/contact/contact-data-access.ts +++ b/hrm-domain/hrm-service/src/contact/contact-data-access.ts @@ -13,7 +13,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see https://www.gnu.org/licenses/. */ - +import { txIfNotInOne } from '../connection-pool'; import { db } from '../connection-pool'; import { UPDATE_CASEID_BY_ID, @@ -31,7 +31,6 @@ import { } from './contact-json'; // eslint-disable-next-line prettier/prettier import type { ITask } from 'pg-promise'; -import { txIfNotInOne } from '../sql'; type ExistingContactRecord = { id: number; diff --git a/hrm-domain/hrm-service/src/csam-report/csam-report-data-access.ts b/hrm-domain/hrm-service/src/csam-report/csam-report-data-access.ts index 5c03cc664..eec317cf8 100644 --- a/hrm-domain/hrm-service/src/csam-report/csam-report-data-access.ts +++ b/hrm-domain/hrm-service/src/csam-report/csam-report-data-access.ts @@ -14,7 +14,7 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ -import { db } from '../connection-pool'; +import { db, txIfNotInOne } from '../connection-pool'; import { insertCSAMReportSql } from './sql/csam-report-insert-sql'; import { selectSingleCsamReportByIdSql, @@ -24,7 +24,6 @@ import { updateContactIdByCsamReportIdsSql, updateAcknowledgedByCsamReportIdSql, } from './sql/csam-report-update-sql'; -import { txIfNotInOne } from '../sql'; export type NewCSAMReport = { reportType: 'counsellor-generated' | 'self-generated'; diff --git a/hrm-domain/hrm-service/src/referral/referral-data-access.ts b/hrm-domain/hrm-service/src/referral/referral-data-access.ts index b975897e8..106e14454 100644 --- a/hrm-domain/hrm-service/src/referral/referral-data-access.ts +++ b/hrm-domain/hrm-service/src/referral/referral-data-access.ts @@ -20,8 +20,8 @@ import { DatabaseForeignKeyViolationError, DatabaseUniqueConstraintViolationError, inferPostgresError, - txIfNotInOne, -} from '../sql'; +} from '@tech-matters/sql'; +import { txIfNotInOne } from '../connection-pool'; // Working around the lack of a 'cause' property in the Error class for ES2020 - can be removed when we upgrade to ES2022 export class DuplicateReferralError extends Error { diff --git a/hrm-domain/hrm-service/src/search.ts b/hrm-domain/hrm-service/src/search.ts index 6c7490dc0..cdc937370 100644 --- a/hrm-domain/hrm-service/src/search.ts +++ b/hrm-domain/hrm-service/src/search.ts @@ -14,7 +14,7 @@ * along with this program. If not, see https://www.gnu.org/licenses/. */ -import { OrderByDirection } from './sql'; +import { OrderByDirection } from '@tech-matters/sql'; export type PaginationQuery = { limit?: string; diff --git a/hrm-domain/hrm-service/unit-tests/case/case-data-access.test.ts b/hrm-domain/hrm-service/unit-tests/case/case-data-access.test.ts index 58988f3cd..1df2bfe1a 100644 --- a/hrm-domain/hrm-service/unit-tests/case/case-data-access.test.ts +++ b/hrm-domain/hrm-service/unit-tests/case/case-data-access.test.ts @@ -21,7 +21,12 @@ import * as caseDb from '../../src/case/case-data-access'; import each from 'jest-each'; import { db } from '../../src/connection-pool'; import { OrderByColumn } from '../../src/case/sql/case-search-sql'; -import { expectValuesInSql, getSqlStatement } from '@tech-matters/testing'; +import { + expectValuesInSql, + getParameterizedSqlStatement, + getSqlStatement, +} from '@tech-matters/testing'; +import { ParameterizedQuery } from 'pg-promise'; const accountSid = 'account-sid'; let conn: pgPromise.ITask; @@ -46,10 +51,11 @@ describe('getById', () => { const result = await caseDb.getById(caseId, accountSid); - expect(oneOrNoneSpy).toHaveBeenCalledWith( - expect.stringContaining('Cases'), - expect.objectContaining({ accountSid, caseId }), - ); + expect(oneOrNoneSpy).toHaveBeenCalledWith(expect.any(ParameterizedQuery)); + const px = oneOrNoneSpy.mock.calls[0][0] as ParameterizedQuery; + expect(px.text).toContain('Cases'); + + expect(px.values).toStrictEqual([accountSid, caseId]); expect(result).toStrictEqual(caseFromDB); }); @@ -59,10 +65,11 @@ describe('getById', () => { const result = await caseDb.getById(caseId, accountSid); - expect(oneOrNoneSpy).toHaveBeenCalledWith( - expect.stringContaining('CSAMReports'), - expect.objectContaining({ accountSid, caseId }), - ); + expect(oneOrNoneSpy).toHaveBeenCalledWith(expect.any(ParameterizedQuery)); + const px = oneOrNoneSpy.mock.calls[0][0] as ParameterizedQuery; + expect(px.text).toContain('CSAMReports'); + + expect(px.values).toStrictEqual([accountSid, caseId]); expect(result).not.toBeDefined(); }); }); @@ -95,7 +102,7 @@ describe('createCase', () => { twilioWorkerId: 'twilio-worker-id', }); const oneSpy = jest.spyOn(tx, 'one').mockResolvedValue({ ...caseFromDB, id: 1337 }); - + const noneSpy = jest.spyOn(tx, 'none'); const result = await caseDb.create(caseFromDB, accountSid); const insertSql = getSqlStatement(oneSpy, -2); const { caseSections, ...caseWithoutSections } = caseFromDB; @@ -105,7 +112,7 @@ describe('createCase', () => { createdAt: expect.anything(), updatedAt: expect.anything(), }); - const insertSectionSql = getSqlStatement(oneSpy, -1); + const insertSectionSql = getSqlStatement(noneSpy); expectValuesInSql(insertSectionSql, { ...caseSections[0], caseId: 1337, @@ -217,13 +224,15 @@ describe('search', () => { { ...createMockCaseRecord({ id: 2 }), totalCount: 1337 }, { ...createMockCaseRecord({ id: 1 }), totalCount: 1337 }, ]; - const anySpy = jest.spyOn(conn, 'any').mockResolvedValue(dbResult); + const manyOrNoneSpy = jest.spyOn(conn, 'manyOrNone').mockResolvedValue(dbResult); const result = await caseDb.search(listConfig, accountSid, {}, filters); - expect(anySpy).toHaveBeenCalledWith( - expect.stringContaining('Cases'), - expect.objectContaining({ ...expectedDbParameters, accountSid }), - ); - const sql = getSqlStatement(anySpy); + expect(manyOrNoneSpy).toHaveBeenCalledWith(expect.any(ParameterizedQuery)); + const pq = manyOrNoneSpy.mock.calls[0][0] as ParameterizedQuery; + expect(pq.text).toContain('Cases'); + Object.values({ ...expectedDbParameters, accountSid }).forEach(expected => { + expect(pq.values).toContainEqual(expected); + }); + const sql = getParameterizedSqlStatement(manyOrNoneSpy); expectedInSql.forEach(expected => { expect(sql).toContain(expected); }); @@ -292,19 +301,22 @@ describe('search', () => { dbResult, expectedResult = dbResult, }) => { - const anySpy = jest.spyOn(conn, 'any').mockResolvedValue(dbResult); + const manyOrNoneSpy = jest.spyOn(conn, 'manyOrNone').mockResolvedValue(dbResult); const result = await caseDb.search(listConfig, accountSid, {}, filters); - expect(anySpy).toHaveBeenCalledWith( - expect.stringContaining('Cases'), - expect.objectContaining({ - contactNumber: null, - firstName: null, - lastName: null, - ...expectedDbParameters, - accountSid, - }), - ); - const statementExecuted = getSqlStatement(anySpy); + expect(manyOrNoneSpy).toHaveBeenCalledWith(expect.any(ParameterizedQuery)); + const pq = manyOrNoneSpy.mock.calls[0][0] as ParameterizedQuery; + + expect(pq.text).toContain('Cases'); + Object.values({ + contactNumber: null, + firstName: null, + lastName: null, + ...expectedDbParameters, + accountSid, + }).forEach(expected => { + expect(pq.values).toContainEqual(expected); + }); + const statementExecuted = getParameterizedSqlStatement(manyOrNoneSpy); expect(statementExecuted).toContain('Contacts'); expect(statementExecuted).toContain('CSAMReports'); expect(result.count).toEqual(1337); @@ -336,16 +348,15 @@ describe('update', () => { .mockResolvedValue({ ...caseUpdateResult, id: caseId }); const noneSpy = jest.spyOn(tx, 'none'); const result = await caseDb.update(caseId, caseUpdate, accountSid); - const updateSql = getSqlStatement(noneSpy, 1); - const selectSql = getSqlStatement(oneOrNoneSpy); + const updateSql = getParameterizedSqlStatement(noneSpy); + const selectSql = getParameterizedSqlStatement(oneOrNoneSpy); + expect(updateSql).toContain('Cases'); expect(selectSql).toContain('Cases'); expect(selectSql).toContain('Contacts'); expect(selectSql).toContain('CSAMReports'); expectValuesInSql(updateSql, { info: caseUpdate.info, status: caseUpdate.status }); - expect(oneOrNoneSpy).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ accountSid, caseId }), - ); + expect(noneSpy).toHaveBeenCalledWith(expect.any(ParameterizedQuery)); + expect(oneOrNoneSpy).toHaveBeenCalledWith(expect.any(ParameterizedQuery)); expect(result).toStrictEqual({ ...caseUpdateResult, id: caseId }); }); }); @@ -357,11 +368,10 @@ describe('delete', () => { const oneOrNoneSpy = jest.spyOn(db, 'oneOrNone').mockResolvedValue(caseFromDB); const result = await caseDb.deleteById(caseId, accountSid); - - expect(oneOrNoneSpy).toHaveBeenCalledWith(expect.stringContaining('Cases'), [ - accountSid, - caseId, - ]); + expect(oneOrNoneSpy).toHaveBeenCalledWith(expect.any(ParameterizedQuery)); + const pq = oneOrNoneSpy.mock.calls[0][0] as ParameterizedQuery; + expect(pq.text).toContain('Cases'); + expect(pq.values).toStrictEqual([accountSid, caseId]); expect(result).toStrictEqual(caseFromDB); }); test('returns nothing if nothing at the specified ID exists to delete', async () => { @@ -370,10 +380,10 @@ describe('delete', () => { const result = await caseDb.deleteById(caseId, accountSid); - expect(oneOrNoneSpy).toHaveBeenCalledWith(expect.stringContaining('Cases'), [ - accountSid, - caseId, - ]); + expect(oneOrNoneSpy).toHaveBeenCalledWith(expect.any(ParameterizedQuery)); + const pq = oneOrNoneSpy.mock.calls[0][0] as ParameterizedQuery; + expect(pq.text).toContain('Cases'); + expect(pq.values).toStrictEqual([accountSid, caseId]); expect(result).not.toBeDefined(); }); }); diff --git a/hrm-domain/hrm-service/unit-tests/mock-db.ts b/hrm-domain/hrm-service/unit-tests/mock-db.ts index 1735d03e8..61e21b273 100644 --- a/hrm-domain/hrm-service/unit-tests/mock-db.ts +++ b/hrm-domain/hrm-service/unit-tests/mock-db.ts @@ -18,11 +18,14 @@ import * as pgPromise from 'pg-promise'; // eslint-disable-next-line import/no-extraneous-dependencies import * as pgMocking from '@tech-matters/testing'; import { db } from '../src/connection-pool'; - -jest.mock('../src/connection-pool', () => ({ - db: pgMocking.createMockConnection(), - pgp: jest.requireActual('../src/connection-pool').pgp, -})); +jest.mock('../src/connection-pool', () => { + const mockDb = pgMocking.createMockConnection(); + return { + db: mockDb, + pgp: jest.requireActual('../src/connection-pool').pgp, + txIfNotInOne: jest.fn().mockImplementation((tx, work) => (tx ?? mockDb).tx(work)), + }; +}); export const mockConnection = pgMocking.createMockConnection; diff --git a/hrm-domain/hrm-service/unit-tests/referrals/referrals-data-access.test.ts b/hrm-domain/hrm-service/unit-tests/referrals/referrals-data-access.test.ts index 2e87918d0..d3a606596 100644 --- a/hrm-domain/hrm-service/unit-tests/referrals/referrals-data-access.test.ts +++ b/hrm-domain/hrm-service/unit-tests/referrals/referrals-data-access.test.ts @@ -16,18 +16,18 @@ import * as pgPromise from 'pg-promise'; import { subHours } from 'date-fns'; +import { + DatabaseForeignKeyViolationError, + DatabaseUniqueConstraintViolationError, + DatabaseError, +} from '@tech-matters/sql'; +import { getSqlStatement } from '@tech-matters/testing'; import { mockConnection, mockTransaction } from '../mock-db'; import * as referralDb from '../../src/referral/referral-data-access'; import { DuplicateReferralError, OrphanedReferralError, } from '../../src/referral/referral-data-access'; -import { - DatabaseForeignKeyViolationError, - DatabaseUniqueConstraintViolationError, - DatabaseError, -} from '../../src/sql'; -import { getSqlStatement } from '@tech-matters/testing'; let conn: pgPromise.ITask; diff --git a/package-lock.json b/package-lock.json index c1401e3e0..1f906bca1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -122,6 +122,7 @@ "dependencies": { "@tech-matters/http": "^1.0.0", "@tech-matters/resources-service": "^1.0.0", + "@tech-matters/sql": "^1.0.0", "@tech-matters/ssm-cache": "^1.0.0", "@tech-matters/twilio-client": "^1.0.0", "@tech-matters/twilio-worker-auth": "^1.0.0", @@ -5026,6 +5027,10 @@ "resolved": "packages/sns-client", "link": true }, + "node_modules/@tech-matters/sql": { + "resolved": "packages/sql", + "link": true + }, "node_modules/@tech-matters/ssm-cache": { "resolved": "packages/ssm-cache", "link": true @@ -13527,6 +13532,36 @@ "aws-sdk": "^2.1225.0" } }, + "packages/sql": { + "name": "@tech-matters/sql", + "version": "1.0.0", + "license": "AGPL", + "dependencies": { + "cors": "^2.8.5", + "express": "^4.17.1", + "http-errors": "^2.0.0", + "morgan": "^1.10.0", + "pg-promise": "^10.11.1" + }, + "devDependencies": { + "@types/morgan": "^1.9.4" + } + }, + "packages/sql/node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "packages/ssm-cache": { "name": "@tech-matters/ssm-cache", "version": "1.0.0", @@ -16771,6 +16806,7 @@ "requires": { "@tech-matters/http": "^1.0.0", "@tech-matters/resources-service": "^1.0.0", + "@tech-matters/sql": "^1.0.0", "@tech-matters/ssm-cache": "^1.0.0", "@tech-matters/testing": "^1.0.0", "@tech-matters/twilio-client": "^1.0.0", @@ -16957,6 +16993,31 @@ "aws-sdk": "^2.1225.0" } }, + "@tech-matters/sql": { + "version": "file:packages/sql", + "requires": { + "@types/morgan": "^1.9.4", + "cors": "^2.8.5", + "express": "^4.17.1", + "http-errors": "^2.0.0", + "morgan": "^1.10.0", + "pg-promise": "^10.11.1" + }, + "dependencies": { + "http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "requires": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + } + } + } + }, "@tech-matters/ssm-cache": { "version": "file:packages/ssm-cache", "requires": { diff --git a/packages/sql/jest.config.js b/packages/sql/jest.config.js new file mode 100644 index 000000000..9a48d1257 --- /dev/null +++ b/packages/sql/jest.config.js @@ -0,0 +1,31 @@ +/** + * Copyright (C) 2021-2023 Technology Matters + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +module.exports = config => { + return ( + config || { + preset: 'ts-jest', + rootDir: './', + maxWorkers: 1, + globals: { + 'ts-jest': { + // to give support to const enum. Not working, conflicting with module resolution + useExperimentalLanguageServer: true, + }, + }, + } + ); +}; diff --git a/packages/sql/package.json b/packages/sql/package.json new file mode 100644 index 000000000..d3bd21837 --- /dev/null +++ b/packages/sql/package.json @@ -0,0 +1,30 @@ +{ + "name": "@tech-matters/sql", + "version": "1.0.0", + "description": "SQL lib to help Aselo service conform to common practices when using pg-promise", + "author": "", + "license": "AGPL", + "main": "dist/index.js", + "dependencies": { + "cors": "^2.8.5", + "express": "^4.17.1", + "http-errors": "^2.0.0", + "morgan": "^1.10.0", + "pg-promise": "^10.11.1" + }, + "scripts": { + "test:unit": "jest tests/unit" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/techmatters/hrm.git" + }, + "keywords": [], + "bugs": { + "url": "https://github.com/techmatters/hrm/issues" + }, + "homepage": "https://github.com/techmatters/hrm#readme", + "devDependencies": { + "@types/morgan": "^1.9.4" + } +} diff --git a/hrm-domain/hrm-service/src/sql.ts b/packages/sql/src/error.ts similarity index 78% rename from hrm-domain/hrm-service/src/sql.ts rename to packages/sql/src/error.ts index 3d9d75789..61d3c8d1d 100644 --- a/hrm-domain/hrm-service/src/sql.ts +++ b/packages/sql/src/error.ts @@ -13,15 +13,6 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see https://www.gnu.org/licenses/. */ -import { ITask } from 'pg-promise'; -import { db } from './connection-pool'; - -export const OrderByDirection = { - ascendingNullsLast: 'ASC NULLS LAST', - descendingNullsLast: 'DESC NULLS LAST', - ascending: 'ASC', - descending: 'DESC', -} as const; export class DatabaseError extends Error { cause: Error; @@ -39,7 +30,7 @@ export class DatabaseConstraintViolationError extends DatabaseError { constraint: string; - constructor(error, table, constraint) { + constructor(error: Error, table: string, constraint: string) { super(error); this.name = 'DatabaseConstraintViolationError'; Object.setPrototypeOf(this, DatabaseConstraintViolationError.prototype); @@ -49,15 +40,15 @@ export class DatabaseConstraintViolationError extends DatabaseError { } export class DatabaseForeignKeyViolationError extends DatabaseConstraintViolationError { - constructor(message, table, constraint) { - super(message, table, constraint); + constructor(error: string | Error, table: string, constraint: string) { + super(typeof error === 'string' ? Error(error) : error, table, constraint); this.name = 'DatabaseForeignKeyViolationError'; Object.setPrototypeOf(this, DatabaseForeignKeyViolationError.prototype); } } export class DatabaseUniqueConstraintViolationError extends DatabaseConstraintViolationError { - constructor(error, table, constraint) { + constructor(error: Error, table: string, constraint: string) { super(error, table, constraint); this.name = 'DatabaseUniqueConstraintViolationError'; Object.setPrototypeOf(this, DatabaseUniqueConstraintViolationError.prototype); @@ -79,13 +70,3 @@ export const inferPostgresError = (rawError: Error): DatabaseError => { return new DatabaseError(rawError); } }; - -export const txIfNotInOne = async ( - task: ITask | undefined, - work: (y: ITask) => Promise, -): Promise => { - if (task) { - return task.txIf(work); - } - return db.tx(work); -}; diff --git a/packages/sql/src/index.ts b/packages/sql/src/index.ts new file mode 100644 index 000000000..b2e5218a6 --- /dev/null +++ b/packages/sql/src/index.ts @@ -0,0 +1,26 @@ +/** + * Copyright (C) 2021-2023 Technology Matters + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +export { parameterizedQuery } from './parameterizedQuery'; +export { + DatabaseUniqueConstraintViolationError, + DatabaseForeignKeyViolationError, + DatabaseError, + DatabaseConstraintViolationError, + inferPostgresError, +} from './error'; + +export { OrderByDirection, OrderByDirectionType } from './ordering'; diff --git a/packages/sql/src/ordering.ts b/packages/sql/src/ordering.ts new file mode 100644 index 000000000..105cc44cd --- /dev/null +++ b/packages/sql/src/ordering.ts @@ -0,0 +1,24 @@ +/** + * Copyright (C) 2021-2023 Technology Matters + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +export const OrderByDirection = { + ascendingNullsLast: 'ASC NULLS LAST', + descendingNullsLast: 'DESC NULLS LAST', + ascending: 'ASC', + descending: 'DESC', +} as const; + +export type OrderByDirectionType = typeof OrderByDirection[keyof typeof OrderByDirection]; diff --git a/packages/sql/src/parameterizedQuery.ts b/packages/sql/src/parameterizedQuery.ts new file mode 100644 index 000000000..de591c98e --- /dev/null +++ b/packages/sql/src/parameterizedQuery.ts @@ -0,0 +1,145 @@ +/** + * Copyright (C) 2021-2023 Technology Matters + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +import { ParameterizedQuery } from 'pg-promise'; + +function isJSONValue(token: string, sql: string): boolean { + // If any of the tokens mark it as JSON, treat it as JSON everywhere + // We can deal with weird corner cases where as value is JSON somoe of the time and not others anon. + return sql.indexOf(`$<${token}:json>`) !== -1; +} + +function escapeRegExp(string: string): string { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function flattenParams( + obj: { [key: string]: any }, + parentKey: string, + sql: string, +): { [key: string]: any } { + const flattened: { [key: string]: any } = {}; + + Object.keys(obj).forEach(key => { + const value = obj[key]; + const fullPath = parentKey ? `${parentKey}.${key}` : key; + + if ( + typeof value === 'object' && + !Array.isArray(value) && + value !== null && + !isJSONValue(fullPath, sql) + ) { + const nestedParams = flattenParams(value, fullPath, sql); + Object.keys(nestedParams).forEach(nestedKey => { + flattened[nestedKey] = nestedParams[nestedKey]; + }); + } else { + flattened[fullPath] = value; + } + }); + + return flattened; +} + +export function convertToPostgreSQLQuery( + sql: string, + params: { [key: string]: any }, + valueCount = 0, +): { query: string; values: any[] } { + let query = sql; + const values: any[] = []; + // Replace named parameters with positional parameters + Object.keys(params).forEach(key => { + const paramValue = params[key]; + const tokenRegex = new RegExp(`\\$<${escapeRegExp(key)}(\:[a-z]+)?\\b>`, 'g'); + if ( + typeof paramValue === 'object' && + !Array.isArray(paramValue) && + paramValue !== null && + !isJSONValue(key, sql) + ) { + // Handle nested objects recursively + const nestedParams = flattenParams(paramValue, key, sql); + const nestedResult = convertToPostgreSQLQuery( + query, + nestedParams, + valueCount + values.length, + ); + query = nestedResult.query; + values.push(...nestedResult.values); + } else if (Array.isArray(paramValue)) { + let singleValueAdded = false; + const positions: string[] = []; + const csvValues: string[] = []; + query = query.replace(tokenRegex, (match, formatSpecifier) => { + switch (formatSpecifier) { + case ':csv': + if (csvValues.length === 0) { + paramValue.forEach((value: any) => { + csvValues.push(value ?? null); + positions.push(`$${values.length + csvValues.length + valueCount}`); + }); + console.debug(`Mapping ${positions.join(', ')} to ${key}, values: ${paramValue}`); + } + return positions.join(', '); + case ':json': + if (!singleValueAdded) { + values.push(JSON.stringify(paramValue)); + singleValueAdded = true; + console.debug( + `Mapping $${values.length + valueCount} to ${key}, value: ${JSON.stringify( + paramValue, + )}`, + ); + } + return `$${values.length + valueCount}`; + default: + if (!singleValueAdded) { + values.push(paramValue); + singleValueAdded = true; + console.debug( + `Mapping $${values.length + valueCount} to ${key}, value: ${paramValue}`, + ); + } + return `$${values.length + valueCount}`; + } + }); + values.push(...csvValues); + } else { + let singleValueAdded = false; + query = query.replace(tokenRegex, () => { + if (!singleValueAdded) { + values.push(paramValue ?? null); + singleValueAdded = true; + console.debug(`Mapping $${values.length + valueCount} to ${key}, value: ${paramValue}`); + } + return `$${values.length + valueCount}`; + }); + } + }); + // console.debug('Parameterized query:', query, values); + return { query, values }; +} + +export function parameterizedQuery( + sql: string, + params: { [key: string]: any }, +): ParameterizedQuery { + const { query, values } = convertToPostgreSQLQuery(sql, params); + + return new ParameterizedQuery({ text: query, values }); +} diff --git a/hrm-domain/hrm-service/unit-tests/sql.test.ts b/packages/sql/tests/unit/error.test.ts similarity index 99% rename from hrm-domain/hrm-service/unit-tests/sql.test.ts rename to packages/sql/tests/unit/error.test.ts index 34128b6e4..9c5896a1b 100644 --- a/hrm-domain/hrm-service/unit-tests/sql.test.ts +++ b/packages/sql/tests/unit/error.test.ts @@ -19,7 +19,7 @@ import { DatabaseUniqueConstraintViolationError, DatabaseError, inferPostgresError, -} from '../src/sql'; +} from '../../src'; describe('inferPostgresError', () => { test('Error with code property set to 23503 - creates DatabaseKeyViolationError, wrapping original and copying table and constraint properties', () => { diff --git a/packages/sql/tests/unit/parameterizedQuery.test.ts b/packages/sql/tests/unit/parameterizedQuery.test.ts new file mode 100644 index 000000000..77e70bcc6 --- /dev/null +++ b/packages/sql/tests/unit/parameterizedQuery.test.ts @@ -0,0 +1,113 @@ +/** + * Copyright (C) 2021-2023 Technology Matters + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +import { parameterizedQuery } from '../../src'; + +describe('parameterizedQuery', () => { + const testCases = [ + { + description: + 'should convert SQL statement and named parameters to PostgreSQL query with positional parameters', + sql: 'SELECT * FROM users WHERE age >= $ AND country IN ($)', + params: { + minAge: 18, + countries: ['USA', 'Canada', 'UK'], + }, + expectedQuery: 'SELECT * FROM users WHERE age >= $1 AND country IN ($2)', + expectedValues: [18, ['USA', 'Canada', 'UK']], + }, + { + description: 'should flatten nested maps of values', + sql: + 'INSERT INTO users (name, address.line1, address.city, settings) VALUES ($, $, $, $)', + params: { + name: 'John Doe', + address: { + line1: '123 Main St', + city: 'New York', + }, + settings: { + darkMode: true, + theme: 'light', + }, + }, + expectedQuery: + 'INSERT INTO users (name, address.line1, address.city, settings) VALUES ($1, $2, $3, $4)', + expectedValues: ['John Doe', '123 Main St', 'New York', { darkMode: true, theme: 'light' }], + }, + { + description: 'should handle array values as single positional parameters', + sql: 'SELECT * FROM users WHERE id IN ($) AND role = $', + params: { + ids: [1, 2, 3], + role: 'admin', + }, + expectedQuery: 'SELECT * FROM users WHERE id IN ($1) AND role = $2', + expectedValues: [[1, 2, 3], 'admin'], + }, + { + description: + 'should handle array values as comma-separated positional parameters when using :csv suffix', + sql: 'SELECT * FROM users WHERE id IN ($) AND role = $', + params: { + ids: [1, 2, 3], + role: 'admin', + }, + expectedQuery: 'SELECT * FROM users WHERE id IN ($1, $2, $3) AND role = $4', + expectedValues: [1, 2, 3, 'admin'], + }, + { + description: 'should handle nested arrays as single positional parameters', + sql: 'INSERT INTO users (name, hobbies) VALUES ($, $)', + params: { + name: 'John Doe', + hobbies: [['reading', 'coding'], ['gaming']], + }, + expectedQuery: 'INSERT INTO users (name, hobbies) VALUES ($1, $2)', + expectedValues: ['John Doe', [['reading', 'coding'], ['gaming']]], + }, + { + description: + 'nested arrays should not be flattened, but be a comma-separated positional set of array parameters when using :csv suffix', + sql: 'INSERT INTO users (name, hobbies_1, hobbies_2) VALUES ($, $)', + params: { + name: 'John Doe', + hobbies: [['reading', 'coding'], ['gaming']], + }, + expectedQuery: 'INSERT INTO users (name, hobbies_1, hobbies_2) VALUES ($1, $2, $3)', + expectedValues: ['John Doe', ['reading', 'coding'], ['gaming']], + }, + { + description: 'should handle JSONB values specified using :json suffix', + sql: 'INSERT INTO users (name, settings) VALUES ($, $)', + params: { + name: 'John Doe', + settings: { + darkMode: true, + theme: 'light', + }, + }, + expectedQuery: 'INSERT INTO users (name, settings) VALUES ($1, $2)', + expectedValues: ['John Doe', { darkMode: true, theme: 'light' }], + }, + ]; + + test.each(testCases)('$description', ({ sql, params, expectedQuery, expectedValues }) => { + const { text, values } = parameterizedQuery(sql, params); + expect(text).toBe(expectedQuery); + expect(values).toEqual(expectedValues); + }); +}); diff --git a/packages/sql/tsconfig.json b/packages/sql/tsconfig.json new file mode 100644 index 000000000..2cab8eff0 --- /dev/null +++ b/packages/sql/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.packages-base.json", + "include": ["src/**/*.ts"], + "compilerOptions": { + "outDir": "./dist" + } +} diff --git a/packages/testing/mock-pgpromise.ts b/packages/testing/mock-pgpromise.ts index 36990e8d2..c7d30552c 100644 --- a/packages/testing/mock-pgpromise.ts +++ b/packages/testing/mock-pgpromise.ts @@ -15,7 +15,7 @@ */ import * as pgPromise from 'pg-promise'; -import { IDatabase, QueryParam } from 'pg-promise'; +import { IDatabase, ParameterizedQuery, QueryParam } from 'pg-promise'; export function createMockConnection(): pgPromise.ITask { return { @@ -94,6 +94,10 @@ export const getSqlStatement = (mockQueryMethod: PgQuerySpy, callIndex = -1): st return mockQueryMethod.mock.calls[callIndex < 0 ? mockQueryMethod.mock.calls.length + callIndex : callIndex][0].toString(); }; +export const getParameterizedSqlStatement = (mockQueryMethod: PgQuerySpy, callIndex = -1): string => { + expect(mockQueryMethod).toHaveBeenCalled(); + return (mockQueryMethod.mock.calls[callIndex < 0 ? mockQueryMethod.mock.calls.length + callIndex : callIndex][0] as ParameterizedQuery).text.toString(); +}; export const getSqlStatementFromNone = (mockQueryMethod: jest.SpyInstance, callIndex = -1): string => { expect(mockQueryMethod).toHaveBeenCalled(); diff --git a/resources-domain/resources-import-producer/index.ts b/resources-domain/resources-import-producer/index.ts index 74a308dfe..87f7fdf1a 100644 --- a/resources-domain/resources-import-producer/index.ts +++ b/resources-domain/resources-import-producer/index.ts @@ -65,7 +65,7 @@ export type KhpApiResponse = { const pullUpdates = (externalApiBaseUrl: URL, externalApiKey: string, externalApiAuthorizationHeader: string) => { const configuredPullUpdates = async (from: Date, to: Date, lastObjectId: string = '', limit = updateBatchSize): Promise => { - const response = await fetch(new URL(`api/resources?sort=updatedAt&fromDate=${from.toISOString()}&toDate=${to.toISOString()}&limit=${updateBatchSize}`, externalApiBaseUrl), { + const response = await fetch(new URL(`api/resources?sort=updatedAt&dateType=updatedAt&startDate=${from.toISOString()}&endDate=${to.toISOString()}&limit=${updateBatchSize}`, externalApiBaseUrl), { headers: { 'Authorization': externalApiAuthorizationHeader, 'x-api-key': externalApiKey, diff --git a/resources-domain/resources-import-producer/tests/unit/handler.test.ts b/resources-domain/resources-import-producer/tests/unit/handler.test.ts index 8acdebc06..e2e54af61 100644 --- a/resources-domain/resources-import-producer/tests/unit/handler.test.ts +++ b/resources-domain/resources-import-producer/tests/unit/handler.test.ts @@ -125,8 +125,8 @@ const testCases: HandlerTestCase[] = [ externalApiResponse: { data: [], totalResults: 0 }, expectedExternalApiCallParameters: [ { - fromDate: new Date(0).toISOString(), - toDate: testNow.toISOString(), + startDate: new Date(0).toISOString(), + endDate: testNow.toISOString(), limit: '1000', }, ], @@ -140,8 +140,8 @@ const testCases: HandlerTestCase[] = [ ], totalResults:2 }, expectedExternalApiCallParameters: [ { - fromDate:new Date(0).toISOString(), - toDate:testNow.toISOString(), + startDate:new Date(0).toISOString(), + endDate:testNow.toISOString(), limit: '1000', }, ], @@ -158,8 +158,8 @@ const testCases: HandlerTestCase[] = [ ], totalResults:2 }, expectedExternalApiCallParameters: [ { - fromDate:addMilliseconds(subHours(testNow, 1), 1).toISOString(), - toDate:testNow.toISOString(), + startDate:addMilliseconds(subHours(testNow, 1), 1).toISOString(), + endDate:testNow.toISOString(), limit: '1000', }, ], diff --git a/tsconfig.build.service.json b/tsconfig.build.service.json index 6ad07ab45..1e7118376 100644 --- a/tsconfig.build.service.json +++ b/tsconfig.build.service.json @@ -7,6 +7,7 @@ { "path": "packages/sns-client" }, { "path": "packages/ssm-cache" }, { "path": "packages/elasticsearch-client" }, + { "path": "packages/sql" }, { "path": "packages/twilio-client" }, { "path": "packages/types" }, { "path": "resources-domain/packages/resources-search-config" }, diff --git a/tsconfig.json b/tsconfig.json index b243b95f6..21abeda95 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,7 @@ { "path": "packages/sns-client" }, { "path": "packages/twilio-client" }, { "path": "packages/twilio-worker-auth" }, + { "path": "packages/sql" }, { "path": "hrm-domain/contact-complete" }, { "path": "hrm-domain/contact-retrieve-recording-url" }, { "path": "hrm-domain/contact-retrieve-transcript" },