Skip to content

Commit

Permalink
feat(#9065): add api support for getting a person with lineage by uuid (
Browse files Browse the repository at this point in the history
  • Loading branch information
jkuester committed Jun 14, 2024
1 parent 1afebb7 commit 8432f5a
Show file tree
Hide file tree
Showing 24 changed files with 1,234 additions and 112 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 = ({ with_lineage }) => ctx.bind(
with_lineage === 'true'
? 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
45 changes: 44 additions & 1 deletion api/tests/mocha/controllers/person.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,24 @@ describe('Person Controller', () => {
const qualifier = Object.freeze({ uuid: 'uuid' });
let byUuid;
let personGet;
let personGetWithLineage;

beforeEach(() => {
req = { params: { uuid: 'uuid' } };
req = {
params: { uuid: 'uuid' },
query: { }
};
byUuid = sinon
.stub(Qualifier, 'byUuid')
.returns(qualifier);
personGet = sinon.stub();
personGetWithLineage = sinon.stub();
dataContextBind
.withArgs(Person.v1.get)
.returns(personGet);
dataContextBind
.withArgs(Person.v1.getWithLineage)
.returns(personGetWithLineage);
});

it('returns a person', async () => {
Expand All @@ -51,6 +59,39 @@ describe('Person Controller', () => {
expect(dataContextBind.calledOnceWithExactly(Person.v1.get)).to.be.true;
expect(byUuid.calledOnceWithExactly(req.params.uuid)).to.be.true;
expect(personGet.calledOnceWithExactly(qualifier)).to.be.true;
expect(personGetWithLineage.notCalled).to.be.true;
expect(res.json.calledOnceWithExactly(person)).to.be.true;
expect(serverUtilsError.notCalled).to.be.true;
});

it('returns a person with lineage when the query parameter is set to "true"', async () => {
const person = { name: 'John Doe' };
personGetWithLineage.resolves(person);
req.query.with_lineage = 'true';

await controller.v1.get(req, res);

expect(authCheck.calledOnceWithExactly(req, 'can_view_contacts')).to.be.true;
expect(dataContextBind.calledOnceWithExactly(Person.v1.getWithLineage)).to.be.true;
expect(byUuid.calledOnceWithExactly(req.params.uuid)).to.be.true;
expect(personGet.notCalled).to.be.true;
expect(personGetWithLineage.calledOnceWithExactly(qualifier)).to.be.true;
expect(res.json.calledOnceWithExactly(person)).to.be.true;
expect(serverUtilsError.notCalled).to.be.true;
});

it('returns a person without lineage when the query parameter is set something else', async () => {
const person = { name: 'John Doe' };
personGet.resolves(person);
req.query.with_lineage = '1';

await controller.v1.get(req, res);

expect(authCheck.calledOnceWithExactly(req, 'can_view_contacts')).to.be.true;
expect(dataContextBind.calledOnceWithExactly(Person.v1.get)).to.be.true;
expect(byUuid.calledOnceWithExactly(req.params.uuid)).to.be.true;
expect(personGet.calledOnceWithExactly(qualifier)).to.be.true;
expect(personGetWithLineage.notCalled).to.be.true;
expect(res.json.calledOnceWithExactly(person)).to.be.true;
expect(serverUtilsError.notCalled).to.be.true;
});
Expand All @@ -64,6 +105,7 @@ describe('Person Controller', () => {
expect(dataContextBind.calledOnceWithExactly(Person.v1.get)).to.be.true;
expect(byUuid.calledOnceWithExactly(req.params.uuid)).to.be.true;
expect(personGet.calledOnceWithExactly(qualifier)).to.be.true;
expect(personGetWithLineage.notCalled).to.be.true;
expect(res.json.notCalled).to.be.true;
expect(serverUtilsError.calledOnceWithExactly(
{ status: 404, message: 'Person not found' },
Expand All @@ -82,6 +124,7 @@ describe('Person Controller', () => {
expect(dataContextBind.notCalled).to.be.true;
expect(byUuid.notCalled).to.be.true;
expect(personGet.notCalled).to.be.true;
expect(personGetWithLineage.notCalled).to.be.true;
expect(res.json.notCalled).to.be.true;
expect(serverUtilsError.calledOnceWithExactly(error, req, res)).to.be.true;
});
Expand Down
6 changes: 4 additions & 2 deletions shared-libs/cht-datasource/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ module.exports = {
'plugin:@typescript-eslint/strict-type-checked',
// https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/src/configs/stylistic-type-checked.ts
'plugin:@typescript-eslint/stylistic-type-checked',
'plugin:jsdoc/recommended-typescript-error'
'plugin:jsdoc/recommended-typescript-error',
'plugin:compat/recommended',
],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint', 'jsdoc'],
plugins: ['@typescript-eslint', 'jsdoc', 'compat'],
parserOptions: {
project: 'tsconfig.json',
tsconfigRootDir: __dirname
Expand All @@ -31,6 +32,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
9 changes: 9 additions & 0 deletions shared-libs/cht-datasource/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,17 @@ export const getDatasource = (ctx: DataContext) => {
* Returns a person by their UUID.
* @param uuid the UUID of the person to retrieve
* @returns the person or `null` if no person is found for the UUID
* @throws Error if no UUID is provided
*/
getByUuid: (uuid: string) => ctx.bind(Person.v1.get)(Qualifier.byUuid(uuid)),

/**
* Returns a person by their UUID along with the person's parent lineage.
* @param uuid the UUID of the person to retrieve
* @returns the person or `null` if no person is found for the UUID
* @throws Error if no UUID is provided
*/
getByUuidWithLineage: (uuid: string) => ctx.bind(Person.v1.getWithLineage)(Qualifier.byUuid(uuid)),
}
}
};
Expand Down
16 changes: 11 additions & 5 deletions shared-libs/cht-datasource/src/libs/contact.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
import { Doc } from './doc';
import { DataObject } from './core';
import { DataObject, Identifiable, isDataObject, isIdentifiable } from './core';

interface NormalizedParent extends DataObject {
readonly _id: string;
/** @internal */
export interface NormalizedParent extends DataObject, Identifiable {
readonly parent?: NormalizedParent;
}

/** @internal */
export interface Contact extends Doc {
export const isNormalizedParent = (value: unknown): value is NormalizedParent => {
return isDataObject(value)
&& isIdentifiable(value)
&& (!value.parent || isNormalizedParent(value.parent));
};

/** @internal */
export interface Contact extends Doc, NormalizedParent {
readonly contact_type?: string;
readonly name?: string;
readonly parent?: NormalizedParent;
readonly reported_date?: Date;
readonly type: string;
}
72 changes: 70 additions & 2 deletions shared-libs/cht-datasource/src/libs/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,71 @@ 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.
*/
export type NonEmptyArray<T> = [T, ...T[]];

/** @internal */
export const isNonEmptyArray = <T>(value: T[]): value is NonEmptyArray<T> => !!value.length;

/** @internal */
export const getLastElement = <T>(array: NonEmptyArray<T>): T => array[array.length - 1];

type DataValue = DataPrimitive | DataArray | DataObject;
type DataPrimitive = string | number | boolean | Date | null | undefined;
interface DataArray extends Readonly<(DataPrimitive | DataArray | DataObject)[]> { }

const isDataPrimitive = (value: unknown): value is DataPrimitive => {
return value === null
|| value === undefined
|| typeof value === 'string'
|| typeof value === 'number'
|| typeof value === 'boolean'
|| value instanceof Date;
};

interface DataArray extends Readonly<DataValue[]> { }

const isDataArray = (value: unknown): value is DataArray => {
return Array.isArray(value) && value.every(v => isDataPrimitive(v) || isDataArray(v) || isDataObject(v));
};

/** @internal */
export interface DataObject extends Readonly<Record<string, DataValue>> { }

/** @internal */
export interface DataObject extends Readonly<Record<string, DataPrimitive | DataArray | DataObject>> { }
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));
};

/**
* 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.fromEntries(
Object
.entries(value)
.map(([key, value]) => [key, deepCopy(value)])
) as unknown as T;
};

/** @internal */
export const isString = (value: unknown): value is string => {
Expand All @@ -38,6 +93,19 @@ export const hasFields = (
fields: NonEmptyArray<{ name: string, type: string }>
): boolean => fields.every(field => hasField(value, field));

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

/** @internal */
export const isIdentifiable = (value: unknown): value is Identifiable => 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' });
25 changes: 25 additions & 0 deletions shared-libs/cht-datasource/src/local/libs/doc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,28 @@ export const getDocById = (db: PouchDB.Database<Doc>) => async (uuid: string): P
logger.error(`Failed to fetch doc with id [${uuid}]`, err);
throw err;
});

/** @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) {
return [];
}
const response = await db.allDocs({ keys, include_docs: true });
return response.rows
.map(({ doc }) => doc)
.filter((doc): doc is Doc => isDoc(doc));
};

/** @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 }) => isDoc(doc) ? doc : null));

Loading

0 comments on commit 8432f5a

Please sign in to comment.