Skip to content

Commit

Permalink
Add support to cht-datasource for getting a person with lineage
Browse files Browse the repository at this point in the history
  • Loading branch information
jkuester committed Jun 11, 2024
1 parent dd82488 commit 3ff82a3
Show file tree
Hide file tree
Showing 18 changed files with 848 additions and 174 deletions.
8 changes: 6 additions & 2 deletions api/src/controllers/person.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,18 @@ const ctx = require('../services/data-context');
const serverUtils = require('../server-utils');
const auth = require('../auth');

const getPerson = (qualifier) => ctx.bind(Person.v1.get)(qualifier);
const getPerson = ({ withLineage }) => ctx.bind(
withLineage
? Person.v1.getWithLineage
: Person.v1.get
);

module.exports = {
v1: {
get: serverUtils.doOrError(async (req, res) => {
await auth.check(req, 'can_view_contacts');
const { uuid } = req.params;
const person = await getPerson(Qualifier.byUuid(uuid));
const person = await getPerson(req.query)(Qualifier.byUuid(uuid));
if (!person) {
return serverUtils.error({ status: 404, message: 'Person not found' }, req, res);
}
Expand Down
1 change: 1 addition & 0 deletions shared-libs/cht-datasource/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ module.exports = {
['@typescript-eslint/no-confusing-void-expression']: ['error', { ignoreArrowShorthand: true }],
['@typescript-eslint/no-empty-interface']: ['error', { allowSingleExtends: true }],
['@typescript-eslint/no-namespace']: 'off',
['@typescript-eslint/no-non-null-assertion']: 'off',
['jsdoc/require-jsdoc']: ['error', {
require: {
ArrowFunctionExpression: true,
Expand Down
32 changes: 29 additions & 3 deletions shared-libs/cht-datasource/src/libs/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import { DataContext } from './data-context';
*/
export type Nullable<T> = T | null;

/** @internal */
export const isNotNull = <T>(value: T | null): value is T => value !== null;

/**
* An array that is guaranteed to have at least one element.
*/
Expand Down Expand Up @@ -38,18 +41,30 @@ export const isDataObject = (value: unknown): value is DataObject => {
if (!isRecord(value)) {
return false;
}
return Object.values(value).every((v) => isDataPrimitive(v) || isDataArray(v) || isDataObject(v));
return Object
.values(value)
.every((v) => isDataPrimitive(v) || isDataArray(v) || isDataObject(v));
};

/** @internal */
/**
* Ideally, this function should only be used at the edge of this library (when returning potentially cross-referenced
* data objects) to avoid unintended consequences if any of the objects are edited in-place. This function should not
* be used for logic internal to this library since all data objects are marked as immutable.
* @internal
*/
export const deepCopy = <T extends DataObject | DataArray | DataPrimitive>(value: T): T => {
if (isDataPrimitive(value)) {
return value;
}
if (isDataArray(value)) {
return value.map(deepCopy) as unknown as T;
}
return { ...Object.values(value).map(deepCopy) } as unknown as T;

return Object.fromEntries(
Object
.entries(value)
.map(([key, value]) => [key, deepCopy(value)])
) as unknown as T;
};

/** @internal */
Expand All @@ -74,6 +89,17 @@ export const hasFields = (
fields: NonEmptyArray<{ name: string, type: string }>
): boolean => fields.every(field => hasField(value, field));

/** @internal */
export interface Identifiable { readonly _id: string }

/** @internal */
export const isIdentifiable = (value: unknown): value is { readonly _id: string } => isRecord(value)
&& hasField(value, { name: '_id', type: 'string' });

/** @internal */
export const findById = <T extends Identifiable>(values: T[], id: string): Nullable<T> => values
.find(v => v._id === id) ?? null;

/** @internal */
export abstract class AbstractDataContext implements DataContext {
readonly bind = <T>(fn: (ctx: DataContext) => T): T => fn(this);
Expand Down
14 changes: 5 additions & 9 deletions shared-libs/cht-datasource/src/libs/doc.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
import { DataObject, hasFields, isRecord } from './core';
import { DataObject, hasField, Identifiable, isIdentifiable, isRecord } from './core';

/**
* A document from the database.
*/
export interface Doc extends DataObject {
readonly _id: string;
export interface Doc extends DataObject, Identifiable {
readonly _rev: string;
}

/** @internal */
export const isDoc = (value: unknown): value is Doc => {
return isRecord(value) && hasFields(value, [
{ name: '_id', type: 'string' },
{ name: '_rev', type: 'string' }
]);
};
export const isDoc = (value: unknown): value is Doc => isRecord(value)
&& isIdentifiable(value)
&& hasField(value, { name: '_rev', type: 'string' });
80 changes: 0 additions & 80 deletions shared-libs/cht-datasource/src/local/libs/contact.ts

This file was deleted.

30 changes: 13 additions & 17 deletions shared-libs/cht-datasource/src/local/libs/doc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,28 +18,24 @@ export const getDocById = (db: PouchDB.Database<Doc>) => async (uuid: string): P
/** @internal */
export const getDocsByIds = (db: PouchDB.Database<Doc>) => async (uuids: string[]): Promise<Doc[]> => {
const keys = Array.from(new Set(uuids.filter(uuid => uuid.length)));
if (keys.length === 0) {
if (!keys.length) {
return [];
}
const response = await db.allDocs({ keys, include_docs: true });
return response.rows
.filter(row => isDoc(row.doc))
.map(row => row.doc as Doc);
.map(({ doc }) => doc)
.filter((doc): doc is Doc => isDoc(doc));
};

/**
* Returns the identified document along with the parent documents recorded for its lineage. The returned array is
* sorted such that the identified document is the first element and the parent documents are in order of lineage.
* @internal
*/
export const getLineageDocsById = (medicDb: PouchDB.Database<Doc>) => (
uuid: string
): Promise<Nullable<Doc>[]> => medicDb
.query('medic-client/docs_by_id_lineage', {
startkey: [uuid],
endkey: [uuid, {}],
/** @internal */
export const queryDocsByKey = (
db: PouchDB.Database<Doc>,
view: string
) => async (key: string): Promise<Nullable<Doc>[]> => db
.query(view, {
startkey: [key],
endkey: [key, {}],
include_docs: true
})
.then(({ rows }) => rows
.map(({ doc }) => doc ?? null)
.filter((doc): doc is Nullable<Doc> => doc === null || isDoc(doc)));
.then(({ rows }) => rows.map(({ doc }) => isDoc(doc) ? doc : null));

83 changes: 83 additions & 0 deletions shared-libs/cht-datasource/src/local/libs/lineage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { Contact, NormalizedParent } from '../../libs/contact';
import {
DataObject,
findById,
isIdentifiable,
isNonEmptyArray,
isNotNull,
NonEmptyArray,
Nullable
} from '../../libs/core';
import { Doc } from '../../libs/doc';
import { queryDocsByKey } from './doc';

/**
* Returns the identified document along with the parent documents recorded for its lineage. The returned array is
* sorted such that the identified document is the first element and the parent documents are in order of lineage.
* @internal
*/
export const getLineageDocsById = (
medicDb: PouchDB.Database<Doc>
): (id: string) => Promise<Nullable<Doc>[]> => queryDocsByKey(medicDb, 'medic-client/docs_by_id_lineage');

/** @internal */
export const getPrimaryContactIds = (places: NonEmptyArray<Nullable<Doc>>): string[] => places
.filter(isNotNull)
.map(({ contact }) => contact)
.filter(isIdentifiable)
.map(({ _id }) => _id)
.filter((_id) => _id.length > 0);

/** @internal */
export const hydratePrimaryContact = (contacts: Doc[]) => (place: Nullable<Doc>): Nullable<Doc> => {
if (!place || !isIdentifiable(place.contact)) {
return place;
}
const contact = findById(contacts, place.contact._id);
if (!contact) {
return place;
}
return {
...place,
contact
};
};

const getParentUuid = (index: number, contact?: NormalizedParent): Nullable<string> => {
if (!contact) {
return null;
}
if (index === 0) {
return contact._id;
}
return getParentUuid(index - 1, contact.parent);
};

const mergeLineage = (lineage: DataObject[], parent: DataObject): DataObject => {
if (!isNonEmptyArray(lineage)) {
return parent;
}
const child = lineage.at(-1)!;
const mergedChild = {
...child,
parent: parent
};
return mergeLineage(lineage.slice(0, -1), mergedChild);
};

/** @internal */
export const hydrateLineage = (
contact: Contact,
lineage: Nullable<Doc>[]
): Contact => {
const fullLineage = lineage
.map((place, index) => {
if (place) {
return place;
}
// If no doc was found, just add a placeholder object with the id from the contact
return { _id: getParentUuid(index, contact.parent) };
});
const hierarchy = [contact, ...fullLineage];
return mergeLineage(hierarchy.slice(0, -1), hierarchy.at(-1)!) as Contact;
};
16 changes: 11 additions & 5 deletions shared-libs/cht-datasource/src/local/person.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { Doc } from '../libs/doc';
import contactTypeUtils from '@medic/contact-types-utils';
import { isNonEmptyArray, Nullable } from '../libs/core';
import { deepCopy, isNonEmptyArray, Nullable } from '../libs/core';
import { UuidQualifier } from '../qualifier';
import * as Person from '../person';
import { getDocById, getLineageDocsById } from './libs/doc';
import { getDocById, getDocsByIds } from './libs/doc';
import { LocalDataContext, SettingsService } from './libs/data-context';
import { getContactWithLineage } from './libs/contact';
import { isNormalizedParent } from '../libs/contact';
import logger from '@medic/logger';
import { getLineageDocsById, getPrimaryContactIds, hydrateLineage, hydratePrimaryContact } from './libs/lineage';

/** @internal */
export namespace v1 {
Expand Down Expand Up @@ -39,17 +39,23 @@ export namespace v1 {
/** @internal */
export const getWithLineage = ({ medicDb, settings }: LocalDataContext) => {
const getLineageDocs = getLineageDocsById(medicDb);
const getMedicDocsById = getDocsByIds(medicDb);
return async (identifier: UuidQualifier): Promise<Nullable<Person.v1.PersonWithLineage>> => {
const [person, ...lineagePlaces] = await getLineageDocs(identifier.uuid);
if (!isPerson(settings, identifier.uuid, person)) {
return null;
}
// Intentionally not validating lineage. For passivity, we do not want lineage problems to block retrieval.
// Intentionally not further validating lineage. For passivity, lineage problems should not block retrieval.
if (!isNonEmptyArray(lineagePlaces)) {
return person;
}

return getContactWithLineage(medicDb, person, lineagePlaces);
const contactUuids = getPrimaryContactIds(lineagePlaces)
.filter(uuid => uuid !== person._id);
const contacts = [person, ...await getMedicDocsById(contactUuids)];
const linagePlacesWithContact = lineagePlaces.map(hydratePrimaryContact(contacts));
const personWithLineage = hydrateLineage(person, linagePlacesWithContact);
return deepCopy(personWithLineage);
};
};
}
15 changes: 15 additions & 0 deletions shared-libs/cht-datasource/src/person.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,19 @@ export namespace v1 {
return getPerson(qualifier);
};
};

/**
* Returns a person for the given qualifier with the person's hierarchy lineage included.
* @param context the current data context
* @returns the person or `null` if no person is found for the qualifier
* @throws Error if the provided context or qualifier is invalid
*/
export const getWithLineage = (context: DataContext) => {
assertDataContext(context);
const getPerson = adapt(context, Local.Person.v1.getWithLineage, Remote.Person.v1.getWithLineage);
return async (qualifier: UuidQualifier): Promise<Nullable<PersonWithLineage>> => {
assertPersonQualifier(qualifier);
return getPerson(qualifier);
};
};
}
Loading

0 comments on commit 3ff82a3

Please sign in to comment.