Skip to content

Commit

Permalink
feat: Add client data to existing AWS exports [CHI-2495] (#589)
Browse files Browse the repository at this point in the history
* refactor: factor out DateFilter type to be shared across modules

* feat: added createdAt/updatedAt base filters to listProfiles service

* feat: added pull-profiles script (debug mode)

* chore: add the pull-profiles script to actual export job

* test: fixed service test

* test: fixed unit test

* Correction to logs in pull profiles

---------

Co-authored-by: mythilytm <mythily@techmatters.org>
  • Loading branch information
GPaoloni and mythilytm authored Mar 12, 2024
1 parent 4a03aac commit 2861a54
Show file tree
Hide file tree
Showing 9 changed files with 283 additions and 21 deletions.
13 changes: 1 addition & 12 deletions hrm-domain/hrm-core/case/caseDataAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import {
import { DELETE_BY_ID } from './sql/case-delete-sql';
import { selectSingleCaseByIdSql } from './sql/caseGetSql';
import { Contact } from '../contact/contactDataAccess';
import { OrderByDirectionType } from '../sql';
import { DateFilter, OrderByDirectionType } from '../sql';
import { TKConditionsSets } from '../permissions/rulesMap';
import { TwilioUser } from '@tech-matters/twilio-worker-auth';
import { AccountSID } from '@tech-matters/types';
Expand Down Expand Up @@ -97,17 +97,6 @@ export type CaseSearchCriteria = {
lastName?: string;
};

export const enum DateExistsCondition {
MUST_EXIST = 'MUST_EXIST',
MUST_NOT_EXIST = 'MUST_NOT_EXIST',
}

export type DateFilter = {
from?: string;
to?: string;
exists?: DateExistsCondition;
};

export type CategoryFilter = {
category: string;
subcategory: string;
Expand Down
3 changes: 2 additions & 1 deletion hrm-domain/hrm-core/case/sql/caseSearchSql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@
*/

import { pgp } from '../../connection-pool';
import { DateExistsCondition, DateFilter } from '../../sql';
import { SELECT_CASE_SECTIONS } from './case-sections-sql';
import { CaseListFilters, DateExistsCondition, DateFilter } from '../caseDataAccess';
import { CaseListFilters } from '../caseDataAccess';
import { selectCoalesceCsamReportsByContactId } from '../../csam-report/sql/csam-report-get-sql';
import { selectCoalesceReferralsByContactId } from '../../referral/sql/referral-get-sql';
import { selectCoalesceConversationMediasByContactId } from '../../conversation-media/sql/conversation-media-get-sql';
Expand Down
2 changes: 1 addition & 1 deletion hrm-domain/hrm-core/profile/profileDataAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ export const listProfiles = async (
accountSid,
limit,
offset,
profileFlagIds: filters?.profileFlagIds,
...filters,
});

const totalCount: number = result.length ? result[0].totalCount : 0;
Expand Down
52 changes: 50 additions & 2 deletions hrm-domain/hrm-core/profile/sql/profile-list-sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@
*/

import { pgp } from '../../connection-pool';
import { OrderByClauseItem, OrderByDirection } from '../../sql';
import {
DateExistsCondition,
DateFilter,
OrderByClauseItem,
OrderByDirection,
} from '../../sql';
import { getProfilesSqlBase } from './profile-get-sql';

export const OrderByColumn = {
Expand Down Expand Up @@ -58,17 +63,60 @@ const listProfilesPaginatedSql = (whereClause: string, orderByClause: string) =>
${orderByClause};
`;

const enum FilterableDateField {
CREATED_AT = 'profiles."createdAt"::TIMESTAMP WITH TIME ZONE',
UPDATED_AT = 'profiles."updatedAt"::TIMESTAMP WITH TIME ZONE',
}

const dateFilterCondition = (
field: FilterableDateField,
filterName: string,
filter: DateFilter,
): string | undefined => {
let existsCondition: string | undefined;
if (filter.exists === DateExistsCondition.MUST_EXIST) {
existsCondition = `(${field} IS NOT NULL)`;
} else if (filter.exists === DateExistsCondition.MUST_NOT_EXIST) {
existsCondition = `(${field} IS NULL)`;
}

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)
${existsCondition ? ` AND ${existsCondition}` : ''})`;
}
return existsCondition;
};

export type ProfilesListFilters = {
profileFlagIds?: number[];
createdAt?: DateFilter;
updatedAt?: DateFilter;
};

const filterSql = ({ profileFlagIds }: ProfilesListFilters) => {
const filterSql = ({ profileFlagIds, createdAt, updatedAt }: ProfilesListFilters) => {
const filterSqlClauses: string[] = [];

if (profileFlagIds && profileFlagIds.length) {
filterSqlClauses.push(
`profiles.id IN (SELECT "profileId" FROM "ProfilesToProfileFlags" WHERE "profileFlagId" IN ($<profileFlagIds:csv>))`,
);
}

if (createdAt) {
filterSqlClauses.push(
dateFilterCondition(FilterableDateField.CREATED_AT, 'createdAt', createdAt),
);
}

if (updatedAt) {
filterSqlClauses.push(
dateFilterCondition(FilterableDateField.UPDATED_AT, 'updatedAt', updatedAt),
);
}

return filterSqlClauses.join(`
AND `);
};
Expand Down
11 changes: 11 additions & 0 deletions hrm-domain/hrm-core/sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,17 @@ export const OrderByDirection = {
descending: 'DESC',
} as const;

export const enum DateExistsCondition {
MUST_EXIST = 'MUST_EXIST',
MUST_NOT_EXIST = 'MUST_NOT_EXIST',
}

export type DateFilter = {
from?: string;
to?: string;
exists?: DateExistsCondition;
};

export type OrderByDirectionType =
(typeof OrderByDirection)[keyof typeof OrderByDirection];

Expand Down
7 changes: 3 additions & 4 deletions hrm-domain/hrm-service/service-tests/case/caseSearch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,9 @@ import each from 'jest-each';
import * as caseApi from '@tech-matters/hrm-core/case/caseService';
import { CaseService, getCase } from '@tech-matters/hrm-core/case/caseService';
import * as caseDb from '@tech-matters/hrm-core/case/caseDataAccess';
import {
CaseListFilters,
DateExistsCondition,
} from '@tech-matters/hrm-core/case/caseDataAccess';
import { CaseListFilters } from '@tech-matters/hrm-core/case/caseDataAccess';
import { DateExistsCondition } from '@tech-matters/hrm-core/sql';

import { db } from '@tech-matters/hrm-core/connection-pool';
import {
fillNameAndPhone,
Expand Down
7 changes: 6 additions & 1 deletion hrm-domain/scheduled-tasks/hrm-data-pull/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import isValid from 'date-fns/isValid';
import { applyContextConfigOverrides } from './context';
import { pullCases } from './pull-cases';
import { pullContacts } from './pull-contacts';
import { pullProfiles } from './pull-profiles';

const isNullUndefinedOrEmptyString = (value: string | null | undefined) =>
value === null || value === undefined || value === '';
Expand Down Expand Up @@ -63,5 +64,9 @@ export const pullData = async (
? getDateRangeForPast12Hours()
: getDateRangeFromArgs(startDateISO, endDateISO);
applyContextConfigOverrides({ shortCodeOverride: hlShortCode });
await Promise.all([pullCases(startDate, endDate), pullContacts(startDate, endDate)]);
await Promise.all([
pullCases(startDate, endDate),
pullContacts(startDate, endDate),
pullProfiles(startDate, endDate),
]);
};
196 changes: 196 additions & 0 deletions hrm-domain/scheduled-tasks/hrm-data-pull/pull-profiles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
/**
* 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 format from 'date-fns/format';
import formatISO from 'date-fns/formatISO';
import { putS3Object } from '@tech-matters/s3-client';
import * as profileApi from '@tech-matters/hrm-core/profile/profileService';
import { getCasesByProfileId } from '@tech-matters/hrm-core/case/caseService';
import {
Contact,
getContactsByProfileId,
} from '@tech-matters/hrm-core/contact/contactService';
import type {
ProfileFlag,
ProfileSection,
ProfileWithRelationships,
} from '@tech-matters/hrm-core/profile/profileDataAccess';

import { getContext, maxPermissions } from './context';
import { autoPaginate } from './auto-paginate';
import { parseISO } from 'date-fns';
import { CaseRecord } from '@tech-matters/hrm-core/case/caseDataAccess';

const getSearchParams = (startDate: Date, endDate: Date) => ({
filters: {
updatedAt: {
from: formatISO(startDate),
to: formatISO(endDate),
},
},
});

const getProfileSectionsForProfile = (p: ProfileWithRelationships) =>
p.profileSections.reduce<Promise<ProfileSection[]>>(
async (prevPromise, { id: sectionId }) => {
const accum = await prevPromise;
const section = (
await profileApi.getProfileSectionById(p.accountSid, {
profileId: p.id,
sectionId,
})
).unwrap();

return [...accum, section];
},
Promise.resolve([]),
);

export const pullProfiles = async (startDate: Date, endDate: Date) => {
const { accountSid, bucket, hrmEnv, shortCode } = await getContext();

try {
const { filters } = getSearchParams(startDate, endDate);

const populatedProfiles = await autoPaginate(async ({ limit, offset }) => {
const profileFlagsR = await profileApi.getProfileFlags(accountSid);
const profileFlags = profileFlagsR
.unwrap()
.reduce<{ [id: ProfileFlag['id']]: ProfileFlag }>(
(acc, curr) => ({
...acc,
[curr.id]: curr,
}),
{},
);

const { count, profiles } = (
await profileApi.listProfiles(
accountSid,
{ limit: limit.toString(), offset: offset.toString() },
{ filters },
// {filters: {profileFlagIds}},
)
).unwrap();

const profilesWithFlags = profiles.map(p => {
return {
...p,
// destructuring on pf as it include validUntil
profileFlags: p.profileFlags.map(pf => ({ ...pf, ...profileFlags[pf.id] })),
};
});

const profilesWithSections = await profilesWithFlags.reduce<
Promise<
(Omit<ProfileWithRelationships, 'profileFlags' | 'profileSections'> & {
profileFlags: ProfileFlag[];
profileSections: ProfileSection[];
})[]
>
>(async (prevPromise, profile) => {
const acc = await prevPromise;
const profileSections = await getProfileSectionsForProfile(profile);
return [...acc, { ...profile, profileSections }];
}, Promise.resolve([]));

const profilesWithContacts = await profilesWithSections.reduce<
Promise<
(Omit<ProfileWithRelationships, 'profileFlags' | 'profileSections'> & {
profileFlags: ProfileFlag[];
profileSections: ProfileSection[];
contactIds: Contact['id'][];
})[]
>
>(async (prevPromise, profile) => {
const acc = await prevPromise;
const contactIds = await autoPaginate(async ({ limit: l, offset: o }) => {
const result = await getContactsByProfileId(
accountSid,
profile.id,
{ limit: l.toString(), offset: o.toString() },
maxPermissions,
);

const { contacts, count: contactsCount } = result.unwrap();

return {
count: contactsCount,
records: contacts.map(c => c.id),
};
});

return [...acc, { ...profile, contactIds }];
}, Promise.resolve([]));

const profilesWithCases = await profilesWithContacts.reduce<
Promise<
(Omit<ProfileWithRelationships, 'profileFlags' | 'profileSections'> & {
profileFlags: ProfileFlag[];
profileSections: ProfileSection[];
contactIds: Contact['id'][];
caseIds: CaseRecord['id'][];
})[]
>
>(async (prevPromise, profile) => {
const acc = await prevPromise;
const caseIds = await autoPaginate(async ({ limit: l, offset: o }) => {
const result = await getCasesByProfileId(
accountSid,
profile.id,
{ limit: l.toString(), offset: o.toString() },
maxPermissions,
);

const { cases, count: casesCount } = result.unwrap();

return {
count: casesCount,
records: cases.map(c => c.id),
};
});

return [...acc, { ...profile, caseIds }];
}, Promise.resolve([]));

return {
records: profilesWithCases,
count: count,
};
});

const uploadPromises = populatedProfiles.map(profile => {
/*
Inner type is slightly wrong. The instance object actually has:
1) 'totalCount' property, which I think is wrong, so I'm deleting it
*/
delete (profile as any).totalCount;
const date = format(parseISO(profile.updatedAt.toISOString()), 'yyyy/MM/dd');
const key = `hrm-data/${date}/profiles/${profile.id}.json`;
const body = JSON.stringify(profile);
const params = { bucket, key, body };

return putS3Object(params);
});

await Promise.all(uploadPromises);
console.log(`>> ${shortCode} ${hrmEnv} Profiles were pulled successfully!`);
} catch (err) {
console.error(`>> Error in ${shortCode} ${hrmEnv} Data Pull: Profiles`);
console.error(err);
// TODO: Should throw an error?
}
};
Loading

0 comments on commit 2861a54

Please sign in to comment.