From dd042950cb4def497b1392cd94489fb178a3acd1 Mon Sep 17 00:00:00 2001 From: Derek Burgman Date: Thu, 7 Nov 2024 14:19:14 -0600 Subject: [PATCH] refactor: added update and upsert to zoho api --- packages/util/src/lib/model/model.ts | 2 + .../src/lib/recruit/recruit.api.spec.ts | 301 +++++++++++++++++- .../nestjs/src/lib/recruit/recruit.api.ts | 10 +- packages/zoho/src/lib/recruit/recruit.api.ts | 169 +++++++--- .../zoho/src/lib/recruit/recruit.error.api.ts | 23 +- packages/zoho/src/lib/recruit/recruit.spec.ts | 21 +- packages/zoho/src/lib/recruit/recruit.ts | 86 +++-- packages/zoho/src/lib/zoho.error.api.ts | 26 ++ 8 files changed, 557 insertions(+), 81 deletions(-) diff --git a/packages/util/src/lib/model/model.ts b/packages/util/src/lib/model/model.ts index 809788339..b6a9920ba 100644 --- a/packages/util/src/lib/model/model.ts +++ b/packages/util/src/lib/model/model.ts @@ -28,6 +28,8 @@ export interface UniqueModel { id?: ModelKey; } +export type UniqueModelWithId = Required; + export interface TypedModel { type: M; } diff --git a/packages/zoho/nestjs/src/lib/recruit/recruit.api.spec.ts b/packages/zoho/nestjs/src/lib/recruit/recruit.api.spec.ts index 1dba3ca93..8ae6325fb 100644 --- a/packages/zoho/nestjs/src/lib/recruit/recruit.api.spec.ts +++ b/packages/zoho/nestjs/src/lib/recruit/recruit.api.spec.ts @@ -4,26 +4,35 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ZohoRecruitApi } from './recruit.api'; import { fileZohoAccountsAccessTokenCacheService, ZohoAccountsAccessTokenCacheService } from '../accounts/accounts.service'; import { expectFail, itShouldFail, jestExpectFailAssertErrorType } from '@dereekb/util/test'; -import { ZOHO_DUPLICATE_DATA_ERROR_CODE, ZOHO_MANDATORY_NOT_FOUND_ERROR_CODE, ZohoNewRecruitRecord, ZohoRecruitRecordCrudDuplicateDataError, ZohoRecruitRecordCrudMandatoryFieldNotFoundError, ZohoRecruitRecordNoContentError } from '@dereekb/zoho'; -import { randomNumber } from '@dereekb/util'; +import { ZOHO_DUPLICATE_DATA_ERROR_CODE, ZOHO_MANDATORY_NOT_FOUND_ERROR_CODE, NewZohoRecruitRecordData, ZohoRecruitRecordCrudDuplicateDataError, ZohoRecruitRecordCrudMandatoryFieldNotFoundError, ZohoRecruitRecordNoContentError, ZohoRecruitRecord, ZohoRecruitRecordCrudNoMatchingRecordError, ZOHO_INVALID_DATA_ERROR_CODE, ZohoRecruitSearchRecordsCriteriaEntry, ZohoRecruitSearchRecordsCriteriaEntryArray, ZohoInvalidQueryError } from '@dereekb/zoho'; +import { Getter, cachedGetter, randomNumber } from '@dereekb/util'; // NOTE: Should have test canidates available on the Zoho Sandbox that is being used. Use test_candidates.csv to generate if needed. const cacheService = fileZohoAccountsAccessTokenCacheService(); -const NON_EXISTENT_CANDIDATE_ID = '01'; +const NON_EXISTENT_CANDIDATE_ID = '576777777777777712'; /** * For the tests, atleast one account should have this email domain/suffix. */ const TEST_ACCOUNT_EXPORT_SUFFIX = 'components.dereekb.com'; const TEST_ACCOUNT_INSERT_EXPORT_SUFFIX = `insert.${TEST_ACCOUNT_EXPORT_SUFFIX}`; +const TEST_ACCOUNT_UPSERT_EXPORT_SUFFIX = `upsert.${TEST_ACCOUNT_EXPORT_SUFFIX}`; /** * This candidate is only avaialble within the specific testing sandbox used for tests. */ const TEST_CANDIDATE_ID = '576214000000574340'; const TEST_CANDIDATE_EMAIL_ADDRESS = 'tester@components.dereekb.com'; +const UPSERT_TEST_FIRST_NAME_PREFIX = `Upsert`; +const UPSERT_TEST_LAST_NAME = `Upsert`; + +interface TestCandidate { + Email: string; + First_Name: string; + Last_Name: string; +} describe('recruit.api', () => { let nest: TestingModule; @@ -53,6 +62,31 @@ describe('recruit.api', () => { describe('ZohoRecruitApi', () => { let api: ZohoRecruitApi; + const GURANTEED_NUMBER_OF_UPSERT_TEST_RECORDS = 2; + + /** + * Cached getter across all test runs. These records should always exist and can be used for updating. + */ + const loadTestRecords: Getter> = cachedGetter(async () => { + const upsertResult = await api.upsertRecord({ + module: 'Candidates', + data: [ + { + First_Name: `${UPSERT_TEST_FIRST_NAME_PREFIX}_1`, + Last_Name: UPSERT_TEST_LAST_NAME, + Email: `upsert+1@${TEST_ACCOUNT_UPSERT_EXPORT_SUFFIX}` + }, + { + First_Name: `${UPSERT_TEST_FIRST_NAME_PREFIX}_2`, + Last_Name: UPSERT_TEST_LAST_NAME, + Email: `upsert+2@${TEST_ACCOUNT_UPSERT_EXPORT_SUFFIX}` + } + ] + }); + + return upsertResult.successItems.map((item) => item.result.details); + }); + beforeEach(() => { api = nest.get(ZohoRecruitApi); }); @@ -136,7 +170,7 @@ describe('recruit.api', () => { it('should return error items for records that could not be created', async () => { const createNumber = randomNumber({ min: 1000000000000, max: 10000000000000 }); - const data: ZohoNewRecruitRecord[] = [ + const data: NewZohoRecruitRecordData[] = [ { First_Name: `Create_${createNumber}`, lastNameFieldMissing: 'Candidate', // field missing @@ -162,6 +196,207 @@ describe('recruit.api', () => { }); }); }); + + describe('updateRecord()', () => { + describe('single record', () => { + it('should update a record and return the updated record details', async () => { + const testRecords = await loadTestRecords(); + const recordToUpdate = testRecords[0]; + + const number = randomNumber({ min: 1000000000000, max: 10000000000000 }); + const First_Name = `Updated For Test ${number}`; + + const updateResult = await api.updateRecord({ + module: 'Candidates', + data: { + id: recordToUpdate.id, + First_Name + } + }); + + expect(updateResult.id).toBe(recordToUpdate.id); + + const updatedRecord = await api.getRecordById({ module: 'Candidates', id: recordToUpdate.id }); + expect(updatedRecord.First_Name).toBe(First_Name); + }); + + itShouldFail('if attempting to update a value that does not exist', async () => { + await expectFail( + () => + api.updateRecord({ + module: 'Candidates', + data: { + id: NON_EXISTENT_CANDIDATE_ID, + First_Name: 'Failure' + } + }), + jestExpectFailAssertErrorType(ZohoRecruitRecordCrudNoMatchingRecordError) + ); + }); + + itShouldFail('if attempting to update a unique value to an existing value', async () => { + const testRecords = await loadTestRecords(); + const recordToUpdate = testRecords[0]; + + await expectFail( + () => + api.updateRecord({ + module: 'Candidates', + data: { + id: recordToUpdate.id, + Email: TEST_CANDIDATE_EMAIL_ADDRESS + } + }), + jestExpectFailAssertErrorType(ZohoRecruitRecordCrudDuplicateDataError) + ); + }); + }); + + describe('multiple record', () => { + it('should update multiple records and return the results in an array', async () => { + const testRecords = await loadTestRecords(); + const recordToUpdate = testRecords[0]; + + const number = randomNumber({ min: 1000000000000, max: 10000000000000 }); + const First_Name = `Updated For Test ${number}`; + + const updateResult = await api.updateRecord({ + module: 'Candidates', + data: [ + { + id: recordToUpdate.id, + First_Name + } + ] + }); + + expect(updateResult.successItems).toHaveLength(1); + expect(updateResult.successItems[0].result.details.id).toBe(recordToUpdate.id); + + const updatedRecord = await api.getRecordById({ module: 'Candidates', id: recordToUpdate.id }); + expect(updatedRecord.First_Name).toBe(First_Name); + }); + + it('should return error items for the items that failed updating', async () => { + const testRecords = await loadTestRecords(); + const recordToUpdate = testRecords[0]; + + const data = [ + { + id: NON_EXISTENT_CANDIDATE_ID, // invalid data issue + First_Name: 'Failure' + }, + { + id: recordToUpdate.id, + Email: TEST_CANDIDATE_EMAIL_ADDRESS // duplicate issue + } + ]; + + const result = await api.updateRecord({ + module: 'Candidates', + data + }); + + expect(result.errorItems).toHaveLength(2); + expect(result.errorItems[0].input).toBe(data[0]); + expect(result.errorItems[1].input).toBe(data[1]); + expect(result.errorItems[0].result.code).toBe(ZOHO_INVALID_DATA_ERROR_CODE); + expect(result.errorItems[1].result.code).toBe(ZOHO_DUPLICATE_DATA_ERROR_CODE); + }); + }); + }); + + describe('upsertRecord()', () => { + describe('single record', () => { + it('should update a record and return the updated record details', async () => { + const testRecords = await loadTestRecords(); + const recordToUpdate = testRecords[0]; + + const number = randomNumber({ min: 1000000000000, max: 10000000000000 }); + const First_Name = `Updated For Test ${number}`; + + const updateResult = await api.upsertRecord({ + module: 'Candidates', + data: { + id: recordToUpdate.id, + First_Name + } + }); + + expect(updateResult.id).toBe(recordToUpdate.id); + + const updatedRecord = await api.getRecordById({ module: 'Candidates', id: recordToUpdate.id }); + expect(updatedRecord.First_Name).toBe(First_Name); + }); + + itShouldFail('if attempting to update a value that does not exist', async () => { + await expectFail( + () => + api.updateRecord({ + module: 'Candidates', + data: { + id: NON_EXISTENT_CANDIDATE_ID, + First_Name: 'Failure' + } + }), + jestExpectFailAssertErrorType(ZohoRecruitRecordCrudNoMatchingRecordError) + ); + }); + }); + + describe('multiple record', () => { + it('should update multiple records and return the results in an array', async () => { + const testRecords = await loadTestRecords(); + const recordToUpdate = testRecords[0]; + + const number = randomNumber({ min: 1000000000000, max: 10000000000000 }); + const First_Name = `Updated For Test ${number}`; + + const updateResult = await api.upsertRecord({ + module: 'Candidates', + data: [ + { + id: recordToUpdate.id, + First_Name + } + ] + }); + + expect(updateResult.successItems).toHaveLength(1); + expect(updateResult.successItems[0].result.details.id).toBe(recordToUpdate.id); + + const updatedRecord = await api.getRecordById({ module: 'Candidates', id: recordToUpdate.id }); + expect(updatedRecord.First_Name).toBe(First_Name); + }); + + it('should return error items for the items that failed updating', async () => { + const testRecords = await loadTestRecords(); + const recordToUpdate = testRecords[0]; + + const data = [ + { + id: NON_EXISTENT_CANDIDATE_ID, // invalid data issue + First_Name: 'Failure' + }, + { + id: recordToUpdate.id, + Email: TEST_CANDIDATE_EMAIL_ADDRESS // duplicate issue + } + ]; + + const result = await api.upsertRecord({ + module: 'Candidates', + data + }); + + expect(result.errorItems).toHaveLength(2); + expect(result.errorItems[0].input).toBe(data[0]); + expect(result.errorItems[1].input).toBe(data[1]); + expect(result.errorItems[0].result.code).toBe(ZOHO_INVALID_DATA_ERROR_CODE); + expect(result.errorItems[1].result.code).toBe(ZOHO_DUPLICATE_DATA_ERROR_CODE); + }); + }); + }); }); describe('read', () => { @@ -202,7 +437,7 @@ describe('recruit.api', () => { }); describe('searchRecords()', () => { - it('should return a page of search results', async () => { + it('should search results by email', async () => { const limit = 3; const result = await api.searchRecords({ module: 'Candidates', @@ -213,6 +448,62 @@ describe('recruit.api', () => { expect(result).toBeDefined(); expect(result.data.length).toBeLessThanOrEqual(limit); }); + + it('should search results by a specific field', async () => { + const limit = GURANTEED_NUMBER_OF_UPSERT_TEST_RECORDS; + + const result = await api.searchRecords({ + module: 'Candidates', + criteria: [{ field: 'Last_Name', filter: 'starts_with', value: UPSERT_TEST_LAST_NAME }], + per_page: limit + }); + + expect(result).toBeDefined(); + expect(result.data).toHaveLength(GURANTEED_NUMBER_OF_UPSERT_TEST_RECORDS); + + expect(result.data[0].Last_Name).toBe(UPSERT_TEST_LAST_NAME); + expect(result.data[1].Last_Name).toBe(UPSERT_TEST_LAST_NAME); + }); + + it('should search results by a specific field', async () => { + const limit = GURANTEED_NUMBER_OF_UPSERT_TEST_RECORDS; + + const result = await api.searchRecords({ + module: 'Candidates', + criteria: [{ field: 'Last_Name', filter: 'starts_with', value: UPSERT_TEST_LAST_NAME }], + per_page: limit + }); + + expect(result).toBeDefined(); + expect(result.data).toHaveLength(GURANTEED_NUMBER_OF_UPSERT_TEST_RECORDS); + + expect(result.data[0].Last_Name).toBe(UPSERT_TEST_LAST_NAME); + expect(result.data[1].Last_Name).toBe(UPSERT_TEST_LAST_NAME); + }); + + it('should return no values if there are no results', async () => { + const limit = GURANTEED_NUMBER_OF_UPSERT_TEST_RECORDS; + + const result = await api.searchRecords({ + module: 'Candidates', + criteria: [{ field: 'Last_Name', filter: 'starts_with', value: 'Should Not Return Any Results' }], + per_page: limit + }); + + expect(result).toBeDefined(); + expect(result.data).toHaveLength(0); + }); + + itShouldFail('if the criteria is invalid', async () => { + await expectFail( + () => + api.searchRecords({ + module: 'Candidates', + criteria: [{ field: 'Last_Name', filter: 'STARTS_WITH_WRONG' as any, value: 'Should Not Return Any Results' }] + }), + jestExpectFailAssertErrorType(ZohoInvalidQueryError) + ); + }); }); }); }); diff --git a/packages/zoho/nestjs/src/lib/recruit/recruit.api.ts b/packages/zoho/nestjs/src/lib/recruit/recruit.api.ts index 3b78421b2..11f9e616f 100644 --- a/packages/zoho/nestjs/src/lib/recruit/recruit.api.ts +++ b/packages/zoho/nestjs/src/lib/recruit/recruit.api.ts @@ -1,5 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; -import { ZohoRecruit, ZohoRecruitContext, getRecordById, getRecords, insertRecord, searchRecords, zohoRecruitFactory } from '@dereekb/zoho'; +import { ZohoRecruit, ZohoRecruitContext, getRecordById, getRecords, insertRecord, searchRecords, updateRecord, upsertRecord, zohoRecruitFactory } from '@dereekb/zoho'; import { ZohoRecruitServiceConfig } from './recruit.config'; import { ZohoAccountsApi } from '../accounts/accounts.api'; @@ -23,6 +23,14 @@ export class ZohoRecruitApi { return insertRecord(this.recruitContext); } + get upsertRecord() { + return upsertRecord(this.recruitContext); + } + + get updateRecord() { + return updateRecord(this.recruitContext); + } + get getRecordById() { return getRecordById(this.recruitContext); } diff --git a/packages/zoho/src/lib/recruit/recruit.api.ts b/packages/zoho/src/lib/recruit/recruit.api.ts index bd83578b2..cdbc048d1 100644 --- a/packages/zoho/src/lib/recruit/recruit.api.ts +++ b/packages/zoho/src/lib/recruit/recruit.api.ts @@ -1,60 +1,74 @@ import { ZohoDataArrayResultRef, ZohoPageResult } from './../zoho.api.page'; import { FetchJsonBody, FetchJsonInput } from '@dereekb/util/fetch'; import { ZohoRecruitContext } from './recruit.config'; -import { ZohoNewRecruitRecord, ZohoRecruitCommaSeparateFieldNames, ZohoRecruitCustomViewId, ZohoRecruitDraftOrSaveState, ZohoRecruitFieldName, ZohoRecruitModuleName, ZohoRecruitRecord, ZohoRecruitRecordId, ZohoRecruitSearchRecordsCriteriaString, ZohoRecruitSearchRecordsCriteriaTree, ZohoRecruitTerritoryId, ZohoRecruitTrueFalseBoth } from './recruit'; -import { EmailAddress, PhoneNumber, SortingOrder, asArray } from '@dereekb/util'; -import { ZohoRecruitRecordCrudError, assertRecordDataArrayResultHasContent, zohoRecruitRecordCrudError } from './recruit.error.api'; -import { ZohoServerErrorData, ZohoServerErrorDataWithDetails, ZohoServerErrorStatus, ZohoServerSuccessCode, ZohoServerSuccessStatus } from '../zoho.error.api'; +import { + NewZohoRecruitRecordData, + UpdateZohoRecruitRecordData, + ZohoRecruitCommaSeparateFieldNames, + ZohoRecruitCustomViewId, + ZohoRecruitDraftOrSaveState, + ZohoRecruitFieldName, + ZohoRecruitModuleName, + ZohoRecruitRecord, + ZohoRecruitRecordFieldsData, + ZohoRecruitRecordId, + ZohoRecruitRecordUpdateDetails, + ZohoRecruitSearchRecordsCriteriaString, + ZohoRecruitSearchRecordsCriteriaTree, + ZohoRecruitSearchRecordsCriteriaTreeElement, + ZohoRecruitTerritoryId, + ZohoRecruitTrueFalseBoth, + zohoRecruitSearchRecordsCriteriaString +} from './recruit'; +import { EmailAddress, Maybe, PhoneNumber, SortingOrder, asArray } from '@dereekb/util'; +import { assertRecordDataArrayResultHasContent, zohoRecruitRecordCrudError } from './recruit.error.api'; +import { ZohoServerErrorDataWithDetails, ZohoServerErrorStatus, ZohoServerSuccessCode, ZohoServerSuccessStatus } from '../zoho.error.api'; export interface ZohoRecruitModuleNameRef { readonly module: ZohoRecruitModuleName; } -// MARK: Insert Record -export type ZohoRecruitInsertRecordInput = ZohoRecruitInsertSingleRecordInput | ZohoRecruitInsertMultiRecordInput; - -export interface ZohoRecruitInsertSingleRecordInput extends ZohoRecruitModuleNameRef { - readonly data: ZohoNewRecruitRecord; -} +// MARK: Insert/Update/Upsert Response +export type ZohoRecruitUpdateRecordResult = ZohoRecruitMultiRecordResult; -export interface ZohoRecruitInsertMultiRecordInput extends ZohoRecruitModuleNameRef { - readonly data: ZohoNewRecruitRecord[]; -} - -export type ZohoRecruitInsertRecordResponse = ZohoDataArrayResultRef; -export type ZohoRecruitInsertRecordResponseEntry = ZohoRecruitInsertRecordResponseSuccessEntry | ZohoRecruitInsertRecordResponseErrorEntry; +export type ZohoRecruitUpdateRecordResponse = ZohoDataArrayResultRef; +export type ZohoRecruitUpdateRecordResponseEntry = ZohoRecruitUpdateRecordResponseSuccessEntry | ZohoRecruitUpdateRecordResponseErrorEntry; -export interface ZohoRecruitInsertRecordResponseSuccessEntry { +export interface ZohoRecruitUpdateRecordResponseSuccessEntry { readonly code: ZohoServerSuccessCode; - readonly details: ZohoRecruitRecord; + readonly details: ZohoRecruitRecordUpdateDetails; readonly status: ZohoServerSuccessStatus; readonly message: string; } -export interface ZohoRecruitInsertRecordResponseErrorEntry extends ZohoServerErrorDataWithDetails { +export interface ZohoRecruitUpdateRecordResponseErrorEntry extends ZohoServerErrorDataWithDetails { readonly status: ZohoServerErrorStatus; } -export type ZohoRecruitInsertRecordResult = ZohoRecruitMultiRecordResult; +export type ZohoRecruitUpdateRecordInput = ZohoRecruitUpdateSingleRecordInput | ZohoRecruitUpdateMultiRecordInput; + +export interface ZohoRecruitUpdateSingleRecordInput extends ZohoRecruitModuleNameRef { + readonly data: RECORD_INPUT_TYPE; +} -export type ZohoRecruitInsertRecordFunction = ZohoRecruitInsertSingleRecordFunction & ZohoRecruitInsertMultiRecordFunction; +export interface ZohoRecruitUpdateMultiRecordInput extends ZohoRecruitModuleNameRef { + readonly data: RECORD_INPUT_TYPE[]; +} -export type ZohoRecruitInsertSingleRecordFunction = (input: ZohoRecruitInsertSingleRecordInput) => Promise; -export type ZohoRecruitInsertMultiRecordFunction = (input: ZohoRecruitInsertMultiRecordInput) => Promise; +export type ZohoRecruitUpdateRecordLikeFunction = ZohoRecruitUpdateMultiRecordFunction & ZohoRecruitUpdateSingleRecordFunction; +export type ZohoRecruitUpdateSingleRecordFunction = (input: ZohoRecruitUpdateSingleRecordInput) => Promise; +export type ZohoRecruitUpdateMultiRecordFunction = (input: ZohoRecruitUpdateMultiRecordInput) => Promise>; /** - * Retrieves a specific record from the given module. + * The APIs for Insert, Upsert, and Update have the same structure. * - * https://www.zoho.com/recruit/developer-guide/apiv2/insert-records.html - * - * @param context * @returns */ -export function insertRecord(context: ZohoRecruitContext): ZohoRecruitInsertRecordFunction { - return (({ data, module }: ZohoRecruitInsertRecordInput) => - context.fetchJson(`/v2/${module}`, zohoRecruitApiFetchJsonInput('POST', { data: asArray(data) })).then((x) => { +function updateRecordLikeFunction(context: ZohoRecruitContext, fetchUrlPrefix: '' | '/upsert', fetchMethod: 'POST' | 'PUT'): ZohoRecruitUpdateRecordLikeFunction { + return (({ data, module }: ZohoRecruitUpdateRecordInput) => + context.fetchJson(`/v2/${module}${fetchUrlPrefix}`, zohoRecruitApiFetchJsonInput(fetchMethod, { data: asArray(data) })).then((x) => { const isInputMultipleItems = Array.isArray(data); - const result = zohoRecruitMultiRecordResult(asArray(data), x.data); + const result = zohoRecruitMultiRecordResult(asArray(data), x.data); if (isInputMultipleItems) { return result; @@ -67,7 +81,52 @@ export function insertRecord(context: ZohoRecruitContext): ZohoRecruitInsertReco return successItems[0].result.details; } } - })) as ZohoRecruitInsertRecordFunction; + })) as ZohoRecruitUpdateRecordLikeFunction; +} + +// MARK: Insert Record +export type ZohoRecruitInsertRecordFunction = ZohoRecruitUpdateRecordLikeFunction; + +/** + * Inserts one or more records into Recruit. + * + * https://www.zoho.com/recruit/developer-guide/apiv2/insert-records.html + * + * @param context + * @returns + */ +export function insertRecord(context: ZohoRecruitContext): ZohoRecruitInsertRecordFunction { + return updateRecordLikeFunction(context, '', 'POST') as ZohoRecruitInsertRecordFunction; +} + +// MARK: Upsert Record +export type ZohoRecruitUpsertRecordFunction = ZohoRecruitUpdateRecordLikeFunction; + +/** + * Updates or inserts one or more records in Recruit. + * + * https://www.zoho.com/recruit/developer-guide/apiv2/upsert-records.html + * + * @param context + * @returns + */ +export function upsertRecord(context: ZohoRecruitContext): ZohoRecruitUpsertRecordFunction { + return updateRecordLikeFunction(context, '/upsert', 'POST') as ZohoRecruitUpsertRecordFunction; +} + +// MARK: Update Record +export type ZohoRecruitUpdateRecordFunction = ZohoRecruitUpdateRecordLikeFunction; + +/** + * Updates one or more records in Recruit. + * + * https://www.zoho.com/recruit/developer-guide/apiv2/update-records.html + * + * @param context + * @returns + */ +export function updateRecord(context: ZohoRecruitContext): ZohoRecruitUpdateRecordFunction { + return updateRecordLikeFunction(context, '', 'PUT') as ZohoRecruitUpdateRecordFunction; } // MARK: Get Record By Id @@ -77,7 +136,8 @@ export interface ZohoRecruitGetRecordByIdInput extends ZohoRecruitModuleNameRef export type ZohoRecruitGetRecordByIdResponse = ZohoDataArrayResultRef; -export type ZohoRecruitGetRecordByIdResult = ZohoRecruitRecord; +export type ZohoRecruitGetRecordByIdResult = T; +export type ZohoRecruitGetRecordByIdFunction = (input: ZohoRecruitGetRecordByIdInput) => Promise>; /** * Retrieves a specific record from the given module. @@ -87,12 +147,12 @@ export type ZohoRecruitGetRecordByIdResult = ZohoRecruitRecord; * @param context * @returns */ -export function getRecordById(context: ZohoRecruitContext): (input: ZohoRecruitGetRecordByIdInput) => Promise { - return (input) => +export function getRecordById(context: ZohoRecruitContext): ZohoRecruitGetRecordByIdFunction { + return (input: ZohoRecruitGetRecordByIdInput) => context .fetchJson(`/v2/${input.module}/${input.id}`, zohoRecruitApiFetchJsonInput('GET')) .then(assertRecordDataArrayResultHasContent(input.module)) - .then((x) => x.data[0]); + .then((x) => x.data[0] as T); } // MARK: Get Records @@ -113,7 +173,8 @@ export interface ZohoRecruitGetRecordsInput extends ZohoRecruitModuleNameRef, Zo readonly $state?: ZohoRecruitDraftOrSaveState; } -export type ZohoRecruitGetRecordsResponse = ZohoPageResult; +export type ZohoRecruitGetRecordsResponse = ZohoPageResult; +export type ZohoRecruitGetRecordsFunction = (input: ZohoRecruitGetRecordsInput) => Promise>; /** * Retrieves records from the given module. Used for paginating across all records. @@ -123,8 +184,8 @@ export type ZohoRecruitGetRecordsResponse = ZohoPageResult; * @param context * @returns */ -export function getRecords(context: ZohoRecruitContext): (input: ZohoRecruitGetRecordsInput) => Promise { - return (input) => context.fetchJson(`/v2/${input.module}?${zohoRecruitUrlSearchParamsMinusModule(input).toString()}`, zohoRecruitApiFetchJsonInput('GET')); +export function getRecords(context: ZohoRecruitContext): ZohoRecruitGetRecordsFunction { + return ((input: ZohoRecruitGetRecordsInput) => context.fetchJson(`/v2/${input.module}?${zohoRecruitUrlSearchParamsMinusModule(input).toString()}`, zohoRecruitApiFetchJsonInput('GET'))) as ZohoRecruitGetRecordsFunction; } // MARK: Search Reecords @@ -133,14 +194,15 @@ export function getRecords(context: ZohoRecruitContext): (input: ZohoRecruitGetR * * Only criteria, email, phone, or word will be used at a single time. */ -export interface ZohoRecruitSearchRecordsInput extends ZohoRecruitModuleNameRef, ZohoRecruitGetRecordsPageFilter { - readonly criteria?: ZohoRecruitSearchRecordsCriteriaString | ZohoRecruitSearchRecordsCriteriaTree; - readonly email?: EmailAddress; - readonly phone?: PhoneNumber; - readonly word?: string; +export interface ZohoRecruitSearchRecordsInput extends ZohoRecruitModuleNameRef, ZohoRecruitGetRecordsPageFilter { + readonly criteria?: Maybe>; + readonly email?: Maybe; + readonly phone?: Maybe; + readonly word?: Maybe; } -export type ZohoRecruitSearchRecordsResponse = ZohoRecruitGetRecordsResponse; +export type ZohoRecruitSearchRecordsResponse = ZohoRecruitGetRecordsResponse; +export type ZohoRecruitSearchRecordsFunction = (input: ZohoRecruitSearchRecordsInput) => Promise>; /** * Searches records from the given module. @@ -150,8 +212,21 @@ export type ZohoRecruitSearchRecordsResponse = ZohoRecruitGetRecordsResponse; * @param context * @returns */ -export function searchRecords(context: ZohoRecruitContext): (input: ZohoRecruitSearchRecordsInput) => Promise { - return (input) => context.fetchJson(`/v2/${input.module}/search?${zohoRecruitUrlSearchParamsMinusModule(input).toString()}`, zohoRecruitApiFetchJsonInput('GET')).then(assertRecordDataArrayResultHasContent(input.module)); +export function searchRecords(context: ZohoRecruitContext): ZohoRecruitSearchRecordsFunction { + function searchRecordsUrlSearchParams(input: ZohoRecruitSearchRecordsInput) { + const baseInput = { ...input }; + delete baseInput.criteria; + + if (input.criteria != null) { + const criteriaString = zohoRecruitSearchRecordsCriteriaString(input.criteria); + baseInput.criteria = criteriaString; + } + + const urlParams = zohoRecruitUrlSearchParamsMinusModule(baseInput); + return urlParams; + } + + return ((input: ZohoRecruitSearchRecordsInput) => context.fetchJson(`/v2/${input.module}/search?${searchRecordsUrlSearchParams(input).toString()}`, zohoRecruitApiFetchJsonInput('GET')).then((x) => x ?? { data: [] })) as ZohoRecruitSearchRecordsFunction; } // MARK: Util diff --git a/packages/zoho/src/lib/recruit/recruit.error.api.ts b/packages/zoho/src/lib/recruit/recruit.error.api.ts index 9e38b89fb..c439f1a9e 100644 --- a/packages/zoho/src/lib/recruit/recruit.error.api.ts +++ b/packages/zoho/src/lib/recruit/recruit.error.api.ts @@ -1,6 +1,6 @@ -import { ConfiguredFetch, FetchResponseError } from '@dereekb/util/fetch'; +import { FetchResponseError } from '@dereekb/util/fetch'; import { BaseError } from 'make-error'; -import { ZohoServerFetchResponseError, ZohoServerErrorDataWithDetails, ZohoServerErrorResponseData, ZohoServerErrorResponseDataError, handleZohoErrorFetchFactory, interceptZohoErrorResponseFactory, logZohoServerErrorFunction, parseZohoServerErrorResponseData, tryFindZohoServerErrorData, zohoServerErrorData, ZohoServerError, ZOHO_MANDATORY_NOT_FOUND_ERROR_CODE, ZOHO_DUPLICATE_DATA_ERROR_CODE, ParsedZohoServerError } from '../zoho.error.api'; +import { ZohoServerErrorDataWithDetails, ZohoServerErrorResponseData, handleZohoErrorFetchFactory, interceptZohoErrorResponseFactory, logZohoServerErrorFunction, parseZohoServerErrorResponseData, tryFindZohoServerErrorData, zohoServerErrorData, ZohoServerError, ZOHO_MANDATORY_NOT_FOUND_ERROR_CODE, ZOHO_DUPLICATE_DATA_ERROR_CODE, ParsedZohoServerError, ZOHO_INVALID_DATA_ERROR_CODE } from '../zoho.error.api'; import { ZohoRecruitModuleName, ZohoRecruitRecordId } from './recruit'; import { ZohoDataArrayResultRef } from '../zoho.api.page'; @@ -15,10 +15,29 @@ export class ZohoRecruitRecordCrudError extends ZohoServerError; + +export class ZohoRecruitRecordCrudInvalidDataError extends ZohoRecruitRecordCrudError { + get invalidFieldDetails(): ZohoRecruitRecordCrudInvalidDataErrorDetails { + return this.error.details as ZohoRecruitRecordCrudInvalidDataErrorDetails; + } +} + +export class ZohoRecruitRecordCrudNoMatchingRecordError extends ZohoRecruitRecordCrudInvalidDataError {} + export function zohoRecruitRecordCrudError(error: ZohoServerErrorDataWithDetails): ZohoRecruitRecordCrudError { let result: ZohoRecruitRecordCrudError; switch (error.code) { + case ZOHO_INVALID_DATA_ERROR_CODE: + const invalidDataError = new ZohoRecruitRecordCrudInvalidDataError(error); + + if (invalidDataError.invalidFieldDetails['id']) { + result = new ZohoRecruitRecordCrudNoMatchingRecordError(error); + } else { + result = invalidDataError; + } + break; case ZOHO_MANDATORY_NOT_FOUND_ERROR_CODE: result = new ZohoRecruitRecordCrudMandatoryFieldNotFoundError(error); break; diff --git a/packages/zoho/src/lib/recruit/recruit.spec.ts b/packages/zoho/src/lib/recruit/recruit.spec.ts index c2f7c04a8..9b0fc7e4e 100644 --- a/packages/zoho/src/lib/recruit/recruit.spec.ts +++ b/packages/zoho/src/lib/recruit/recruit.spec.ts @@ -1,15 +1,22 @@ -import { ZohoRecruitSearchRecordsCriteriaTree, zohoRecruitSearchRecordsCriteriaString } from './recruit'; +import { ZohoRecruitSearchRecordsCriteriaEntry, ZohoRecruitSearchRecordsCriteriaTree, zohoRecruitSearchRecordsCriteriaString } from './recruit'; describe('zohoRecruitSearchRecordsCriteriaString()', () => { + describe('entries array', () => { + it('should convert an array of entries', () => { + const tree: ZohoRecruitSearchRecordsCriteriaEntry[] = [ + { field: 'testA', filter: 'contains', value: 'a' }, + { field: 'testB', filter: 'contains', value: 'b' } + ]; + + const result = zohoRecruitSearchRecordsCriteriaString(tree); + expect(result).toBe(`((testA:contains:a)and(testB:contains:b))`); + }); + }); + describe('trees', () => { it('should convert a tree of AND values', () => { const tree: ZohoRecruitSearchRecordsCriteriaTree = { - and: [ - [ - { field: 'testA', filter: 'contains', value: 'a' }, - { field: 'testB', filter: 'contains', value: 'b' } - ] - ] + and: [] }; const result = zohoRecruitSearchRecordsCriteriaString(tree); diff --git a/packages/zoho/src/lib/recruit/recruit.ts b/packages/zoho/src/lib/recruit/recruit.ts index 975f96a63..6eee6c309 100644 --- a/packages/zoho/src/lib/recruit/recruit.ts +++ b/packages/zoho/src/lib/recruit/recruit.ts @@ -1,4 +1,4 @@ -import { CommaSeparatedString, ISO8601DateString, Maybe, PageNumber, escapeStringCharactersFunction, replaceStringsFunction, convertToArray, filterMaybeValues, ArrayOrValue, asArray } from '@dereekb/util'; +import { CommaSeparatedString, ISO8601DateString, Maybe, PageNumber, escapeStringCharactersFunction, replaceStringsFunction, convertToArray, filterMaybeValues, ArrayOrValue, asArray, UniqueModelWithId, ISO8601DateStringUTCFull } from '@dereekb/util'; /** * Zoho Recruit module name. @@ -43,18 +43,39 @@ export interface ZohoRecruitCreatedByData { id: string; // TODO: figure out what kind of id this is } -export type ZohoNewRecruitRecord = Record & { +export type ZohoRecruitRecordFieldsData = Record; + +export interface ZohoRecordDraftStateData { /** - * Use "draft" if the record should be created as a draft. + * Used to update a draft record or to convert a draft to a normal record. + * + * When creating, passing "draft" will create the record as a draft. */ $state?: ZohoRecruitDraftOrSaveState; -}; +} + +export type NewZohoRecruitRecordData = ZohoRecruitRecordFieldsData & ZohoRecordDraftStateData; + +/** + * A ZohoRecruit record containing the corresponding record's id. + */ +export type UpdateZohoRecruitRecordData = UniqueModelWithId & ZohoRecruitRecordFieldsData & ZohoRecordDraftStateData; -export type ZohoRecruitRecord = Record & { +/** + * A ZohoRecruit record containing record details. + */ +export type ZohoRecruitRecord = UniqueModelWithId & ZohoRecruitRecordFieldsData; + +/** + * Update details returned by the server for an updated object. + */ +export interface ZohoRecruitRecordUpdateDetails { id: ZohoRecruitRecordId; - Updated_On: ISO8601DateString; + Modified_Time: ISO8601DateString; + Modified_By: ZohoRecruitCreatedByData; + Created_Time: ISO8601DateString; Created_By: ZohoRecruitCreatedByData; -}; +} /** * Encoded criteria string. @@ -66,8 +87,33 @@ export type ZohoRecruitSearchRecordsCriteriaString = string; * * If the input tree is empty, returns undefined. */ -export function zohoRecruitSearchRecordsCriteriaString(tree: ZohoRecruitSearchRecordsCriteriaTree): Maybe { - function convertToString(value: Maybe): Maybe> { +export function zohoRecruitSearchRecordsCriteriaString(input: Maybe>): Maybe { + let result: Maybe; + + if (input != null) { + switch (typeof input) { + case 'string': + result = input; + break; + case 'object': + let tree: ZohoRecruitSearchRecordsCriteriaTree; + + if (Array.isArray(input)) { + tree = { and: [input] }; + } else { + tree = input; + } + + result = zohoRecruitSearchRecordsCriteriaStringForTree(tree); + break; + } + } + + return result; +} + +export function zohoRecruitSearchRecordsCriteriaStringForTree(tree: ZohoRecruitSearchRecordsCriteriaTree): Maybe { + function convertToString(value: Maybe>): Maybe> { let result: Maybe>; if (typeof value === 'object') { @@ -76,7 +122,7 @@ export function zohoRecruitSearchRecordsCriteriaString(tree: ZohoRecruitSearchRe result = value.map(zohoRecruitSearchRecordsCriteriaEntryToCriteriaString); } else if (value) { // criteria tree that first needs to be converted to a string - result = zohoRecruitSearchRecordsCriteriaString(value); + result = zohoRecruitSearchRecordsCriteriaStringForTree(value); } } else { result = value; @@ -89,7 +135,7 @@ export function zohoRecruitSearchRecordsCriteriaString(tree: ZohoRecruitSearchRe return values.length > 1 ? `(${values.join(type)})` : values[0]; // wrap in and values } - function mergeValues(values: Maybe[], type: 'and' | 'or'): ZohoRecruitSearchRecordsCriteriaString { + function mergeValues(values: Maybe>[], type: 'and' | 'or'): ZohoRecruitSearchRecordsCriteriaString { const allStrings = filterMaybeValues(values.map(convertToString)).flatMap(asArray); return mergeStringValues(allStrings, type); } @@ -109,23 +155,25 @@ export function zohoRecruitSearchRecordsCriteriaString(tree: ZohoRecruitSearchRe * * If both AND and OR values are provided at the root tree, then the will be merged together with AND. */ -export interface ZohoRecruitSearchRecordsCriteriaTree { +export interface ZohoRecruitSearchRecordsCriteriaTree { /** * Items to AND with eachother */ - readonly and?: Maybe; + readonly and?: Maybe[]>; /** * Items to OR with eachother */ - readonly or?: Maybe; + readonly or?: Maybe[]>; } -export type ZohoRecruitSearchRecordsCriteriaTreeElement = ZohoRecruitSearchRecordsCriteriaTree | ZohoRecruitSearchRecordsCriteriaEntry[] | ZohoRecruitSearchRecordsCriteriaString; +export type ZohoRecruitSearchRecordsCriteriaTreeElement = ZohoRecruitSearchRecordsCriteriaEntryArray | ZohoRecruitSearchRecordsCriteriaTree | ZohoRecruitSearchRecordsCriteriaString; + +export type ZohoRecruitSearchRecordsCriteriaFilterType = 'starts_with' | 'equals' | 'contains'; -export type ZohoRecruitSearchRecordsCriteriaFilterType = 'starts_With' | 'equals' | 'contains'; +export type ZohoRecruitSearchRecordsCriteriaEntryArray = ZohoRecruitSearchRecordsCriteriaEntry[]; -export interface ZohoRecruitSearchRecordsCriteriaEntry { - readonly field: ZohoRecruitFieldName; +export interface ZohoRecruitSearchRecordsCriteriaEntry { + readonly field: keyof T extends string ? keyof T : never; readonly filter: ZohoRecruitSearchRecordsCriteriaFilterType; readonly value: string; } @@ -147,7 +195,7 @@ export const escapeZohoFieldValueForCriteriaString = escapeStringCharactersFunct * @param entry * @returns */ -export function zohoRecruitSearchRecordsCriteriaEntryToCriteriaString(entry: ZohoRecruitSearchRecordsCriteriaEntry): ZohoRecruitSearchRecordsCriteriaString { +export function zohoRecruitSearchRecordsCriteriaEntryToCriteriaString(entry: ZohoRecruitSearchRecordsCriteriaEntry): ZohoRecruitSearchRecordsCriteriaString { const escapedValue = escapeZohoFieldValueForCriteriaString(entry.value); return `(${entry.field}:${entry.filter}:${escapedValue})`; } diff --git a/packages/zoho/src/lib/zoho.error.api.ts b/packages/zoho/src/lib/zoho.error.api.ts index c8a476081..61bab34d7 100644 --- a/packages/zoho/src/lib/zoho.error.api.ts +++ b/packages/zoho/src/lib/zoho.error.api.ts @@ -186,6 +186,24 @@ export const ZOHO_INVALID_AUTHORIZATION_ERROR_CODE = '4834'; */ export class ZohoInvalidAuthorizationError extends ZohoServerFetchResponseError {} +/** + * Error in the following cases: + * - Search query is invalid + */ +export const ZOHO_INVALID_QUERY_ERROR_CODE = 'INVALID_QUERY'; + +export interface ZohoInvalidQueryErrorDetails { + readonly reason: string; + readonly api_name: string; + readonly operator: string; +} + +export class ZohoInvalidQueryError extends ZohoServerFetchResponseError { + get details() { + return this.error.details as ZohoInvalidQueryErrorDetails; + } +} + /** * Error when a mandatory field is missing. */ @@ -196,6 +214,11 @@ export const ZOHO_MANDATORY_NOT_FOUND_ERROR_CODE = 'MANDATORY_NOT_FOUND'; */ export const ZOHO_DUPLICATE_DATA_ERROR_CODE = 'DUPLICATE_DATA'; +/** + * Error when some passed data is invalid. + */ +export const ZOHO_INVALID_DATA_ERROR_CODE = 'INVALID_DATA'; + /** * Function that parses/transforms a ZohoServerErrorResponseData into a general ZohoServerError or other known error type. * @@ -217,6 +240,9 @@ export function parseZohoServerErrorResponseData(errorResponseData: ZohoServerEr case ZOHO_INVALID_AUTHORIZATION_ERROR_CODE: result = new ZohoInvalidAuthorizationError(errorData, responseError); break; + case ZOHO_INVALID_QUERY_ERROR_CODE: + result = new ZohoInvalidQueryError(errorData, responseError); + break; default: result = new ZohoServerFetchResponseError(errorData, responseError); break;