Skip to content

Commit

Permalink
feat(#9193): add functionality of getting places as pages or async it…
Browse files Browse the repository at this point in the history
…erables in cht-datasource
  • Loading branch information
sugat009 committed Aug 23, 2024
1 parent 34dd030 commit c103b2b
Show file tree
Hide file tree
Showing 12 changed files with 251 additions and 78 deletions.
24 changes: 20 additions & 4 deletions api/src/controllers/place.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,35 @@ const getPlace = ({ with_lineage }) => ctx.bind(
: Place.v1.get
);

const getPageByType = () => ctx.bind(Place.v1.getPage);

const checkUserPermissions = async (req) => {
const userCtx = await auth.getUserCtx(req);
if (!auth.isOnlineOnly(userCtx) || !auth.hasAllPermissions(userCtx, 'can_view_contacts')) {
return Promise.reject({ code: 403, message: 'Insufficient privileges' });
}
};

module.exports = {
v1: {
get: serverUtils.doOrError(async (req, res) => {
const userCtx = await auth.getUserCtx(req);
if (!auth.isOnlineOnly(userCtx) || !auth.hasAllPermissions(userCtx, 'can_view_contacts')) {
return Promise.reject({ code: 403, message: 'Insufficient privileges' });
}
await checkUserPermissions(req);
const { uuid } = req.params;
const place = await getPlace(req.query)(Qualifier.byUuid(uuid));
if (!place) {
return serverUtils.error({ status: 404, message: 'Place not found' }, req, res);
}
return res.json(place);
}),
getAll: serverUtils.doOrError(async (req, res) => {
await checkUserPermissions(req);

const placeType = Qualifier.byContactType(req.query.placeType);
const limit = req.query.limit ? Number(req.query.limit) : req.query.limit;

const docs = await getPageByType()( placeType, req.query.cursor, limit );

return res.json(docs);
})
}
};
1 change: 1 addition & 0 deletions api/src/routing.js
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,7 @@ app.postJson('/api/v1/places/:id', function(req, res) {
.catch(err => serverUtils.error(err, req, res));
});

app.get('/api/v1/places', place.v1.getAll);
app.get('/api/v1/place/:uuid', place.v1.get);

app.postJson('/api/v1/people', function(req, res) {
Expand Down
14 changes: 14 additions & 0 deletions shared-libs/cht-datasource/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,20 @@ export const getDatasource = (ctx: DataContext) => {
* @throws Error if no UUID is provided
*/
getByUuidWithLineage: (uuid: string) => ctx.bind(Place.v1.getWithLineage)(Qualifier.byUuid(uuid)),

/**
* TODO: Add jsdoc
* @param personType
* @param cursor
* @param limit
*/
getPageByType: (
personType: string,
cursor: Nullable<string> = null,
limit = 100
) => ctx.bind(Person.v1.getPage)(
Qualifier.byContactType(personType), cursor, limit
),
},
person: {
/**
Expand Down
72 changes: 72 additions & 0 deletions shared-libs/cht-datasource/src/libs/core.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { DataContext } from './data-context';
import { Doc } from './doc';
import { SettingsService } from '../local/libs/data-context';
import logger from '@medic/logger';
import { ContactTypeQualifier, isContactTypeQualifier } from '../qualifier';
import { InvalidArgumentError } from './error';

/**
* A value that could be `null`.
Expand Down Expand Up @@ -144,3 +149,70 @@ export const getPagedGenerator = async function* <S, T>(

return null;
};

/** @internal */
export const assertTypeQualifier: (qualifier: unknown) => asserts qualifier is ContactTypeQualifier = (
qualifier: unknown
) => {
if (!isContactTypeQualifier(qualifier)) {
throw new InvalidArgumentError(`Invalid contact type [${JSON.stringify(qualifier)}].`);
}
};

/** @internal */
export const assertLimit: (limit: unknown) => asserts limit is number = (limit: unknown) => {
if (typeof limit !== 'number' || !Number.isInteger(limit) || limit <= 0) {
throw new InvalidArgumentError(`The limit must be a positive number: [${String(limit)}]`);
}
};

/** @internal */
export const assertCursor: (cursor: unknown) => asserts cursor is Nullable<string> = (cursor: unknown) => {
if (cursor !== null && (typeof cursor !== 'string' || !cursor.length)) {
throw new InvalidArgumentError(`Invalid cursor token: [${String(cursor)}]`);
}
};

/** @internal */
export const fetchAndFilter = (
getFunction: (key: unknown, limit: number, skip: number) => Promise<Nullable<Doc>[]>,
filterFunction: (settings: SettingsService, doc: Nullable<Doc>, uuid: string | undefined) => unknown,
settings: SettingsService,
contactType: string,
limit: number,
): typeof recursionInner => {
const recursionInner = async (
currentLimit: number,
currentSkip: number,
currentDocs: Nullable<Doc>[] = [],
): Promise<Page<unknown>> => {
const docs = await getFunction([contactType], currentLimit, currentSkip);
const noMoreResults = docs.length < currentLimit;
const newDocs = docs.filter((doc) => filterFunction(settings, doc, doc?._id));
const overFetchCount = currentDocs.length + newDocs.length - limit || 0;
const totalDocs = [...currentDocs, ...newDocs].slice(0, limit);

if (noMoreResults) {
return {data: totalDocs, cursor: null};
}

if (totalDocs.length === limit) {
const nextSkip = currentSkip + currentLimit - overFetchCount;

return {data: totalDocs, cursor: nextSkip.toString()};
}

// Re-fetch twice as many docs as we need to limit number of recursions
const missingCount = currentLimit - newDocs.length;
logger.debug(`Found [${missingCount.toString()}] invalid docs. Re-fetching additional records.`);
const nextLimit = missingCount * 2;
const nextSkip = currentSkip + currentLimit;

return recursionInner(
nextLimit,
nextSkip,
totalDocs,
);
};
return recursionInner;
};
44 changes: 8 additions & 36 deletions shared-libs/cht-datasource/src/local/person.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Doc } from '../libs/doc';
import contactTypeUtils from '@medic/contact-types-utils';
import { deepCopy, isNonEmptyArray, Nullable, Page } from '../libs/core';
import { deepCopy, fetchAndFilter, isNonEmptyArray, Nullable, Page } from '../libs/core';
import { ContactTypeQualifier, UuidQualifier } from '../qualifier';
import * as Person from '../person';
import { getDocById, getDocsByIds, queryDocsByKey } from './libs/doc';
Expand Down Expand Up @@ -82,41 +82,13 @@ export namespace v1 {
throw new InvalidArgumentError(`Invalid cursor token: [${String(cursor)}]`);
}

const fetchAndFilter = async (
currentLimit: number,
currentSkip: number,
currentPersonDocs: Person.v1.Person[] = [],
): Promise<Page<Person.v1.Person>> => {
const docs = await getDocsByPage([personType.contactType], currentLimit, currentSkip);
const noMoreResults = docs.length < currentLimit;
const newPersonDocs = docs.filter((doc): doc is Person.v1.Person => isPerson(settings, doc, doc?._id));
const overFetchCount = currentPersonDocs.length + newPersonDocs.length - limit || 0;
const totalPeople = [...currentPersonDocs, ...newPersonDocs].slice(0, limit);

if (noMoreResults) {
return { data: totalPeople, cursor: null };
}

if (totalPeople.length === limit) {
const nextSkip = currentSkip + currentLimit - overFetchCount;

return { data: totalPeople, cursor: nextSkip.toString() };
}

// Re-fetch twice as many docs as we need to limit number of recursions
const missingCount = currentLimit - newPersonDocs.length;
logger.debug(`Found [${missingCount.toString()}] invalid persons. Re-fetching additional records.`);
const nextLimit = missingCount * 2;
const nextSkip = currentSkip + currentLimit;

return fetchAndFilter(
nextLimit,
nextSkip,
totalPeople,
);
};

return fetchAndFilter(limit, skip);
return await fetchAndFilter(
getDocsByPage,
isPerson,
settings,
personType.contactType,
limit
)(limit, skip) as Page<Person.v1.Person>;
};
};
}
45 changes: 39 additions & 6 deletions shared-libs/cht-datasource/src/local/place.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import { Doc } from '../libs/doc';
import contactTypeUtils from '@medic/contact-types-utils';
import { deepCopy, isNonEmptyArray, NonEmptyArray, Nullable } from '../libs/core';
import { UuidQualifier } from '../qualifier';
import { deepCopy, fetchAndFilter, isNonEmptyArray, NonEmptyArray, Nullable, Page } from '../libs/core';
import { ContactTypeQualifier, UuidQualifier } from '../qualifier';
import * as Place from '../place';
import { getDocById, getDocsByIds } from './libs/doc';
import { getDocById, getDocsByIds, queryDocsByKey } from './libs/doc';
import { LocalDataContext, SettingsService } from './libs/data-context';
import { Contact } from '../libs/contact';
import logger from '@medic/logger';
import { getLineageDocsById, getPrimaryContactIds, hydrateLineage, hydratePrimaryContact } from './libs/lineage';
import { InvalidArgumentError } from '../libs/error';

/** @internal */
export namespace v1 {
const isPlace = (settings: SettingsService, uuid: string, doc: Nullable<Doc>): doc is Place.v1.Place => {
const isPlace = (settings: SettingsService, doc: Nullable<Doc>, uuid = ''): doc is Place.v1.Place => {
if (!doc) {
logger.warn(`No place found for identifier [${uuid}].`);
return false;
Expand All @@ -29,7 +30,7 @@ export namespace v1 {
const getMedicDocById = getDocById(medicDb);
return async (identifier: UuidQualifier): Promise<Nullable<Place.v1.Place>> => {
const doc = await getMedicDocById(identifier.uuid);
const validPlace = isPlace(settings, identifier.uuid, doc);
const validPlace = isPlace(settings, doc, identifier.uuid);
return validPlace ? doc : null;
};
};
Expand All @@ -40,7 +41,7 @@ export namespace v1 {
const getMedicDocsById = getDocsByIds(medicDb);
return async (identifier: UuidQualifier): Promise<Nullable<Place.v1.PlaceWithLineage>> => {
const [place, ...lineagePlaces] = await getLineageDocs(identifier.uuid);
if (!isPlace(settings, identifier.uuid, place)) {
if (!isPlace(settings, place, identifier.uuid)) {
return null;
}
// Intentionally not further validating lineage. For passivity, lineage problems should not block retrieval.
Expand All @@ -57,4 +58,36 @@ export namespace v1 {
return deepCopy(placeWithLineage);
};
};

/** @internal */
export const getPage = ({ medicDb, settings }: LocalDataContext) => {
const getDocsByPage = queryDocsByKey(medicDb, 'medic-client/contacts_by_type');

return async (
placeType: ContactTypeQualifier,
cursor: Nullable<string>,
limit: number
): Promise<Page<Place.v1.Place>> => {
const placeTypes = contactTypeUtils.getPlaceTypes(settings.getAll());
const placeTypeIds = placeTypes.map(p => p.id);

if (!placeTypeIds.includes(placeType.contactType)) {
throw new InvalidArgumentError(`Invalid contact type [${placeType.contactType}].`);
}

// Adding a number skip variable here so as not to confuse ourselves
const skip = Number(cursor);
if (isNaN(skip) || skip < 0 || !Number.isInteger(skip)) {
throw new InvalidArgumentError(`Invalid cursor token: [${String(cursor)}]`);
}

return await fetchAndFilter(
getDocsByPage,
isPlace,
settings,
placeType.contactType,
limit
)(limit, skip) as Page<Place.v1.Place>;
};
};
}
24 changes: 2 additions & 22 deletions shared-libs/cht-datasource/src/person.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ContactTypeQualifier, isContactTypeQualifier, isUuidQualifier, UuidQualifier } from './qualifier';
import { ContactTypeQualifier, isUuidQualifier, UuidQualifier } from './qualifier';
import { adapt, assertDataContext, DataContext } from './libs/data-context';
import { Contact, NormalizedParent } from './libs/contact';
import * as Remote from './remote';
Expand All @@ -7,7 +7,7 @@ import * as Place from './place';
import { LocalDataContext } from './local/libs/data-context';
import { RemoteDataContext } from './remote/libs/data-context';
import { InvalidArgumentError } from './libs/error';
import { getPagedGenerator, Nullable, Page } from './libs/core';
import { assertCursor, assertLimit, assertTypeQualifier, getPagedGenerator, Nullable, Page } from './libs/core';

/** */
export namespace v1 {
Expand All @@ -34,26 +34,6 @@ export namespace v1 {
}
};

const assertTypeQualifier: (qualifier: unknown) => asserts qualifier is ContactTypeQualifier = (
qualifier: unknown
) => {
if (!isContactTypeQualifier(qualifier)) {
throw new InvalidArgumentError(`Invalid contact type [${JSON.stringify(qualifier)}].`);
}
};

const assertLimit: (limit: unknown) => asserts limit is number = (limit: unknown) => {
if (typeof limit !== 'number' || !Number.isInteger(limit) || limit <= 0) {
throw new InvalidArgumentError(`The limit must be a positive number: [${String(limit)}]`);
}
};

const assertCursor: (cursor: unknown) => asserts cursor is Nullable<string> = (cursor: unknown) => {
if (cursor !== null && (typeof cursor !== 'string' || !cursor.length)) {
throw new InvalidArgumentError(`Invalid cursor token: [${String(cursor)}]`);
}
};

const getPerson = <T>(
localFn: (c: LocalDataContext) => (qualifier: UuidQualifier) => Promise<T>,
remoteFn: (c: RemoteDataContext) => (qualifier: UuidQualifier) => Promise<T>
Expand Down
56 changes: 54 additions & 2 deletions shared-libs/cht-datasource/src/place.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { Contact, NormalizedParent } from './libs/contact';
import * as Person from './person';
import { LocalDataContext } from './local/libs/data-context';
import { isUuidQualifier, UuidQualifier } from './qualifier';
import { LocalDataContext} from './local/libs/data-context';
import {ContactTypeQualifier, isUuidQualifier, UuidQualifier} from './qualifier';
import { RemoteDataContext } from './remote/libs/data-context';
import { adapt, assertDataContext, DataContext } from './libs/data-context';
import * as Local from './local';
import * as Remote from './remote';
import {assertCursor, assertLimit, assertTypeQualifier, getPagedGenerator, Nullable, Page} from './libs/core';

/** */
export namespace v1 {
Expand Down Expand Up @@ -60,4 +61,55 @@ export namespace v1 {
* @throws Error if the provided context or qualifier is invalid
*/
export const getWithLineage = getPlace(Local.Place.v1.getWithLineage, Remote.Place.v1.getWithLineage);

/**
* TODO: add jsdoc
* @param context
*/
export const getPage = (
context: DataContext
): typeof curriedFn => {
assertDataContext(context);
const fn = adapt(context, Local.Place.v1.getPage, Remote.Place.v1.getPage);

/**
* TODO: Add jsdoc
* @param placeType
* @param cursor
* @param limit
*/
const curriedFn = async (
placeType: ContactTypeQualifier,
cursor: Nullable<string> = null,
limit = 100
): Promise<Page<Place>> => {
assertTypeQualifier(placeType);
assertCursor(cursor);
assertLimit(limit);

return fn(placeType, cursor, limit);
};
return curriedFn;
};

/**
* TODO: Add JSDoc
* @param context
*/
export const getAll = (
context: DataContext
): typeof curriedGen => {
assertDataContext(context);
const getPage = context.bind(v1.getPage);

/**
* Add JSDoc
* @param placeType
*/
const curriedGen = (placeType: ContactTypeQualifier) => {
assertTypeQualifier(placeType);
return getPagedGenerator(getPage, placeType);
};
return curriedGen;
};
}
Loading

0 comments on commit c103b2b

Please sign in to comment.