diff --git a/api/src/controllers/place.js b/api/src/controllers/place.js index 869d2b625ee..7fabe701f26 100644 --- a/api/src/controllers/place.js +++ b/api/src/controllers/place.js @@ -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); }) } }; diff --git a/api/src/routing.js b/api/src/routing.js index e192d40840d..eed60481217 100644 --- a/api/src/routing.js +++ b/api/src/routing.js @@ -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) { diff --git a/shared-libs/cht-datasource/src/index.ts b/shared-libs/cht-datasource/src/index.ts index b4186f68dd5..5b61c79b55b 100644 --- a/shared-libs/cht-datasource/src/index.ts +++ b/shared-libs/cht-datasource/src/index.ts @@ -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 = null, + limit = 100 + ) => ctx.bind(Person.v1.getPage)( + Qualifier.byContactType(personType), cursor, limit + ), }, person: { /** diff --git a/shared-libs/cht-datasource/src/libs/core.ts b/shared-libs/cht-datasource/src/libs/core.ts index d1c87becbbc..fa4eb00401b 100644 --- a/shared-libs/cht-datasource/src/libs/core.ts +++ b/shared-libs/cht-datasource/src/libs/core.ts @@ -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`. @@ -144,3 +149,70 @@ export const getPagedGenerator = async function* ( 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 = (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[]>, + filterFunction: (settings: SettingsService, doc: Nullable, uuid: string | undefined) => unknown, + settings: SettingsService, + contactType: string, + limit: number, +): typeof recursionInner => { + const recursionInner = async ( + currentLimit: number, + currentSkip: number, + currentDocs: Nullable[] = [], + ): Promise> => { + 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; +}; diff --git a/shared-libs/cht-datasource/src/local/person.ts b/shared-libs/cht-datasource/src/local/person.ts index 301b3cf44ba..b545f5cd4ff 100644 --- a/shared-libs/cht-datasource/src/local/person.ts +++ b/shared-libs/cht-datasource/src/local/person.ts @@ -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'; @@ -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> => { - 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; }; }; } diff --git a/shared-libs/cht-datasource/src/local/place.ts b/shared-libs/cht-datasource/src/local/place.ts index 9cd38be30e7..b27db115312 100644 --- a/shared-libs/cht-datasource/src/local/place.ts +++ b/shared-libs/cht-datasource/src/local/place.ts @@ -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 is Place.v1.Place => { + const isPlace = (settings: SettingsService, doc: Nullable, uuid = ''): doc is Place.v1.Place => { if (!doc) { logger.warn(`No place found for identifier [${uuid}].`); return false; @@ -29,7 +30,7 @@ export namespace v1 { const getMedicDocById = getDocById(medicDb); return async (identifier: UuidQualifier): Promise> => { const doc = await getMedicDocById(identifier.uuid); - const validPlace = isPlace(settings, identifier.uuid, doc); + const validPlace = isPlace(settings, doc, identifier.uuid); return validPlace ? doc : null; }; }; @@ -40,7 +41,7 @@ export namespace v1 { const getMedicDocsById = getDocsByIds(medicDb); return async (identifier: UuidQualifier): Promise> => { 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. @@ -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, + limit: number + ): Promise> => { + 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; + }; + }; } diff --git a/shared-libs/cht-datasource/src/person.ts b/shared-libs/cht-datasource/src/person.ts index 4694f14ebe4..8d20f85c887 100644 --- a/shared-libs/cht-datasource/src/person.ts +++ b/shared-libs/cht-datasource/src/person.ts @@ -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'; @@ -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 { @@ -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 = (cursor: unknown) => { - if (cursor !== null && (typeof cursor !== 'string' || !cursor.length)) { - throw new InvalidArgumentError(`Invalid cursor token: [${String(cursor)}]`); - } - }; - const getPerson = ( localFn: (c: LocalDataContext) => (qualifier: UuidQualifier) => Promise, remoteFn: (c: RemoteDataContext) => (qualifier: UuidQualifier) => Promise diff --git a/shared-libs/cht-datasource/src/place.ts b/shared-libs/cht-datasource/src/place.ts index 0e301b51b7c..d4759c3ce05 100644 --- a/shared-libs/cht-datasource/src/place.ts +++ b/shared-libs/cht-datasource/src/place.ts @@ -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 { @@ -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 = null, + limit = 100 + ): Promise> => { + 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; + }; } diff --git a/shared-libs/cht-datasource/src/remote/place.ts b/shared-libs/cht-datasource/src/remote/place.ts index 6aef2228c62..29eb2820715 100644 --- a/shared-libs/cht-datasource/src/remote/place.ts +++ b/shared-libs/cht-datasource/src/remote/place.ts @@ -1,12 +1,14 @@ -import { Nullable } from '../libs/core'; -import { UuidQualifier } from '../qualifier'; +import { Nullable, Page } from '../libs/core'; +import {ContactTypeQualifier, UuidQualifier} from '../qualifier'; import * as Place from '../place'; -import { getResource, RemoteDataContext } from './libs/data-context'; +import { getResource, getResources, RemoteDataContext } from './libs/data-context'; /** @internal */ export namespace v1 { const getPlace = (remoteContext: RemoteDataContext) => getResource(remoteContext, 'api/v1/place'); + const getPlaces = (remoteContext: RemoteDataContext) => getResources(remoteContext, 'api/v1/places'); + /** @internal */ export const get = (remoteContext: RemoteDataContext) => ( identifier: UuidQualifier @@ -19,4 +21,18 @@ export namespace v1 { identifier.uuid, { with_lineage: 'true' } ); + + /** @internal */ + export const getPage = (remoteContext: RemoteDataContext) => ( + placeType: ContactTypeQualifier, + cursor: Nullable, + limit: number, + ): Promise> => { + const queryParams = { + 'limit': limit.toString(), + 'placeType': placeType.contactType, + ...(cursor ? { cursor } : {}) + }; + return getPlaces(remoteContext)(queryParams); + }; } diff --git a/shared-libs/cht-datasource/test/index.spec.ts b/shared-libs/cht-datasource/test/index.spec.ts index 882b6c3c338..4356888093d 100644 --- a/shared-libs/cht-datasource/test/index.spec.ts +++ b/shared-libs/cht-datasource/test/index.spec.ts @@ -53,7 +53,7 @@ describe('CHT Script API - getDatasource', () => { beforeEach(() => place = v1.place); it('contains expected keys', () => { - expect(place).to.have.all.keys(['getByUuid', 'getByUuidWithLineage']); + expect(place).to.have.all.keys(['getByUuid', 'getByUuidWithLineage', 'getPageByType']); }); it('getByUuid', async () => { diff --git a/shared-libs/cht-datasource/test/local/person.spec.ts b/shared-libs/cht-datasource/test/local/person.spec.ts index af1d5265da7..74a7da8cc23 100644 --- a/shared-libs/cht-datasource/test/local/person.spec.ts +++ b/shared-libs/cht-datasource/test/local/person.spec.ts @@ -304,14 +304,12 @@ describe('local person', () => { [ {}, - '', '-1', undefined, - false ].forEach((invalidSkip ) => { it(`throws an error if cursor is invalid: ${String(invalidSkip)}`, async () => { - await expect(Person.v1.getPage(localContext)(invalidPersonTypeQualifier, invalidSkip as string, limit)) - .to.be.rejectedWith(`Invalid contact type [${invalidPersonTypeQualifier.contactType}]`); + await expect(Person.v1.getPage(localContext)(personTypeQualifier, invalidSkip as string, limit)) + .to.be.rejectedWith(`Invalid cursor token: [${String(invalidSkip)}]`); expect(settingsGetAll.calledOnce).to.be.true; expect(getPersonTypes.calledOnceWithExactly(settings)).to.be.true; diff --git a/tests/integration/api/controllers/place.spec.js b/tests/integration/api/controllers/place.spec.js index 019b02f4cb7..700bec2caac 100644 --- a/tests/integration/api/controllers/place.spec.js +++ b/tests/integration/api/controllers/place.spec.js @@ -96,4 +96,23 @@ describe('Place API', () => { }); }); }); + + describe('Place.v1.getAll', async () => { + const placeType = 'clinic'; + + // TODO: this test is a bit non-covering of the actual generator functionality + // as only one place could be generated with the same type from the placeFactory + // figure out a way to generate more places with the same type + it('fetches all data by iterating through generator', async () => { + const docs = []; + + const generator = Place.v1.getAll(dataContext)(Qualifier.byContactType(placeType)); + + for await (const doc of generator) { + docs.push(doc); + } + + expect(docs).excluding(['_rev', 'reported_date']).to.deep.equal([place0]); + }); + }); });