Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(9586): implement freetext search in cht datasource #9625

Draft
wants to merge 5 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions api/src/controllers/contact.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
const auth = require('../auth');
const { Contact, Qualifier} = require('@medic/cht-datasource');
const ctx = require('../services/data-context');
const serverUtils = require('../server-utils');

const getContact = ({ with_lineage }) => ctx.bind(
with_lineage === 'true'
? Contact.v1.getWithLineage
: Contact.v1.get
);

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) => {
await checkUserPermissions(req);
const { uuid } = req.params;
const contact = await getContact(req.query)(Qualifier.byUuid(uuid));

if (!contact) {
return serverUtils.error({ status: 404, message: 'Contact not found' }, req, res);
}

return res.json(contact);
})
}
};
3 changes: 3 additions & 0 deletions api/src/routing.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const exportData = require('./controllers/export-data');
const records = require('./controllers/records');
const forms = require('./controllers/forms');
const users = require('./controllers/users');
const contact = require('./controllers/contact');
const person = require('./controllers/person');
const place = require('./controllers/place');
const { people, places } = require('@medic/contacts')(config, db, dataContext);
Expand Down Expand Up @@ -485,6 +486,8 @@ app.postJson('/api/v1/people', function(req, res) {
app.get('/api/v1/person', person.v1.getAll);
app.get('/api/v1/person/:uuid', person.v1.get);

app.get('/api/v1/contact/:uuid', contact.v1.get);

app.postJson('/api/v1/bulk-delete', bulkDocs.bulkDelete);

// offline users are not allowed to hydrate documents via the hydrate API
Expand Down
125 changes: 125 additions & 0 deletions shared-libs/cht-datasource/src/contact.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { Doc } from './libs/doc';
import { DataObject, Identifiable, isDataObject, isIdentifiable } from './libs/core';
import { isUuidQualifier, UuidQualifier } from './qualifier';
import { adapt, assertDataContext, DataContext } from './libs/data-context';
import { LocalDataContext } from './local/libs/data-context';
import { RemoteDataContext } from './remote/libs/data-context';
import { InvalidArgumentError } from './libs/error';
import * as Local from './local';
import * as Remote from './remote';

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

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

/**
* Immutable data about a Contact.
*/
export interface Contact extends Doc, NormalizedParent {
readonly contact_type?: string;
readonly name?: string;
readonly reported_date?: Date;
readonly type: string;
}

/**
* Immutable data about a contact, including the full records of the parent's lineage.
*/
export interface ContactWithLineage extends Contact {
readonly parent?: ContactWithLineage | NormalizedParent;
}

const assertContactQualifier: (qualifier: unknown) => asserts qualifier is UuidQualifier = (qualifier: unknown) => {
if (!isUuidQualifier(qualifier)) {
throw new InvalidArgumentError(`Invalid identifier [${JSON.stringify(qualifier)}].`);
}
};

const getContact = <T>(
localFn: (c: LocalDataContext) => (qualifier: UuidQualifier) => Promise<T>,
remoteFn: (c: RemoteDataContext) => (qualifier: UuidQualifier) => Promise<T>
) => (context: DataContext) => {
assertDataContext(context);
const fn = adapt(context, localFn, remoteFn);
return async (qualifier: UuidQualifier): Promise<T> => {
assertContactQualifier(qualifier);
return fn(qualifier);
};
};

/**
* Returns a function for retrieving a contact from the given data context.
* @param context the current data context
* @returns a function for retrieving a contact
* @throws Error if a data context is not provided
*/
/**
* Returns a contact for the given qualifier.
* @param qualifier identifier for the contact to retrieve
* @returns the contact or `null` if no contact is found for the qualifier
* @throws Error if the qualifier is invalid
*/
export const get = getContact(Local.Contact.v1.get, Remote.Contact.v1.get);

/**
* Returns a function for retrieving a contact from the given data context with the contact's parent lineage.
* @param context the current data context
* @returns a function for retrieving a contact with the contact's parent lineage
* @throws Error if a data context is not provided
*/
/**
* Returns a contact for the given qualifier with the contact's parent lineage.
* @param qualifier identifier for the contact to retrieve
* @returns the contact or `null` if no contact is found for the qualifier
* @throws Error if the qualifier is invalid
*/
export const getWithLineage = getContact(Local.Contact.v1.getWithLineage, Remote.Contact.v1.getWithLineage);

// New REST api: /api/v1/contact/id
/**
* Returns a function for retrieving a paged array of contact identifiers from the given data context.
* @param context the current data context
* @returns a function for retrieving a paged array of contact identifiers
* @throws Error if a data context is not provided
* @see {@link getIdsAll} which provides the same data, but without having to manually account for paging
*/
/**
* Returns an array of contact identifiers for the provided page specifications.
* @param qualifier the limiter defining which identifiers to return
* @param cursor the token identifying which page to retrieve. A `null` value indicates the first page should be
* returned. Subsequent pages can be retrieved by providing the cursor returned with the previous page.
* @param limit the maximum number of identifiers to return. Default is 10000.
* @returns a page of contact identifiers for the provided specification
* @throws Error if no qualifier is provided or if the qualifier is invalid
* @throws Error if the provided `limit` value is `<=0`
* @throws Error if the provided cursor is not a valid page token or `null`
*/
// const getIdsPage = (context: DataContext) => (
// qualifier: ContactTypeQualifier | FreetextQualifier,
// cursor: Nullable<string>, limit: number
// ) => Promise<Page<string>>;

/**
* Returns a function for getting a generator that fetches contact identifiers from the given data context.
* @param context the current data context
* @returns a function for getting a generator that fetches contact identifiers
* @throws Error if a data context is not provided
*/
/**
* Returns a generator for fetching all contact identifiers that match the given qualifier
* @param qualifier the limiter defining which identifiers to return
* @returns a generator for fetching all contact identifiers that match the given qualifier
* @throws Error if no qualifier is provided or if the qualifier is invalid
*/
// const getIdsAll = (context: DataContext) => (
// qualifier: ContactTypeQualifier | FreetextQualifier
// ) => AsyncGenerator<string, null>;
}
13 changes: 11 additions & 2 deletions shared-libs/cht-datasource/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import { hasAnyPermission, hasPermissions } from './auth';
import { Nullable } from './libs/core';
import { assertDataContext, DataContext } from './libs/data-context';
import * as Contact from './contact';
import * as Person from './person';
import * as Place from './place';
import * as Qualifier from './qualifier';
Expand All @@ -38,6 +39,7 @@ export { DataContext } from './libs/data-context';
export { getLocalDataContext } from './local';
export { getRemoteDataContext } from './remote';
export { InvalidArgumentError } from './libs/error';
export * as Contact from './contact';
export * as Person from './person';
export * as Place from './place';
export * as Qualifier from './qualifier';
Expand All @@ -54,6 +56,13 @@ export const getDatasource = (ctx: DataContext) => {
v1: {
hasPermissions,
hasAnyPermission,
contact: {
/** TODO */
getByUuid: (uuid: string) => ctx.bind(Contact.v1.get)(Qualifier.byUuid(uuid)),

/** TODO */
getByUuidWithLineage: (uuid: string) => ctx.bind(Contact.v1.getWithLineage)(Qualifier.byUuid(uuid)),
},
place: {
/**
* Returns a place by its UUID.
Expand Down Expand Up @@ -143,7 +152,7 @@ export const getDatasource = (ctx: DataContext) => {
* @throws InvalidArgumentError if no type is provided or if the type is not for a person
*/
getByType: (personType: string) => ctx.bind(Person.v1.getAll)(Qualifier.byContactType(personType)),
}
}
},
},
};
};
22 changes: 0 additions & 22 deletions shared-libs/cht-datasource/src/libs/contact.ts

This file was deleted.

2 changes: 1 addition & 1 deletion shared-libs/cht-datasource/src/libs/data-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export interface DataContext {
* @param fn the function to execute
* @returns the result of the function
*/
bind: <T>(fn: (ctx: DataContext) => T) => T
bind: <T>(fn: (ctx: DataContext) => T) => T;
}

const isDataContext = (context: unknown): context is DataContext => {
Expand Down
67 changes: 67 additions & 0 deletions shared-libs/cht-datasource/src/local/contact.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { LocalDataContext, SettingsService } from './libs/data-context';
import { getDocById, getDocsByIds } from './libs/doc';
import { UuidQualifier } from '../qualifier';
import * as Contact from '../contact';
import { deepCopy, isNonEmptyArray, Nullable } from '../libs/core';
import { Doc } from '../libs/doc';
import logger from '@medic/logger';
import contactTypeUtils from '@medic/contact-types-utils';
import { getLineageDocsById, getPrimaryContactIds, hydrateLineage, hydratePrimaryContact } from './libs/lineage';

/** @internal */
export namespace v1 {
const isContact =
(settings: SettingsService) => (doc: Nullable<Doc>, uuid?: string): doc is Contact.v1.Contact => {
if (!doc) {
if (uuid) {
logger.warn(`No contact found for identifier [${uuid}].`);
}
return false;
}

const contactTypes = contactTypeUtils.getContactTypes(settings.getAll());
const contactTypesIds = contactTypes.map((item) => item.id);
if (!contactTypesIds.includes(doc.type)) {
logger.warn(`Document [${doc._id}] is not a valid contact.`);
return false;
}
return true;
};

/** @internal */
export const get = ({ medicDb, settings }: LocalDataContext) => {
const getMedicDocById = getDocById(medicDb);
return async (identifier: UuidQualifier): Promise<Nullable<Contact.v1.Contact>> => {
const doc = await getMedicDocById(identifier.uuid);
if (!isContact(settings)(doc, identifier.uuid)) {
return null;
}

return doc;
};
};

/** @internal */
export const getWithLineage = ({ medicDb, settings }: LocalDataContext) => {
const getLineageDocs = getLineageDocsById(medicDb);
const getMedicDocsById = getDocsByIds(medicDb);
return async (identifier: UuidQualifier): Promise<Nullable<Contact.v1.Contact>> => {
const [contact, ...lineageContacts] = await getLineageDocs(identifier.uuid);
if (!isContact(settings)(contact, identifier.uuid)) {
return null;
}

if (!isNonEmptyArray(lineageContacts)) {
logger.debug(`No lineage places found for person [${identifier.uuid}].`);
return contact;
}

const contactUuids = getPrimaryContactIds(lineageContacts)
.filter((uuid) => uuid !== identifier.uuid);
const contacts = [contact, ...(await getMedicDocsById(contactUuids))];
const lineageContactsWithContact = lineageContacts.map(hydratePrimaryContact(contacts));
const contactWithLineage = hydrateLineage(contact, lineageContactsWithContact);
return deepCopy(contactWithLineage);
};
};
}
1 change: 1 addition & 0 deletions shared-libs/cht-datasource/src/local/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * as Contact from './contact';
export * as Person from './person';
export * as Place from './place';
export { getLocalDataContext } from './libs/data-context';
10 changes: 5 additions & 5 deletions shared-libs/cht-datasource/src/local/libs/lineage.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Contact, NormalizedParent } from '../../libs/contact';
import * as Contact from '../../contact';
import {
DataObject,
findById,
Expand Down Expand Up @@ -47,7 +47,7 @@ export const hydratePrimaryContact = (contacts: Doc[]) => (place: Nullable<Doc>)
};
};

const getParentUuid = (index: number, contact?: NormalizedParent): Nullable<string> => {
const getParentUuid = (index: number, contact?: Contact.v1.NormalizedParent): Nullable<string> => {
if (!contact) {
return null;
}
Expand All @@ -71,9 +71,9 @@ const mergeLineage = (lineage: DataObject[], parent: DataObject): DataObject =>

/** @internal */
export const hydrateLineage = (
contact: Contact,
contact: Contact.v1.Contact,
lineage: Nullable<Doc>[]
): Contact => {
): Contact.v1.Contact => {
const fullLineage = lineage
.map((place, index) => {
if (place) {
Expand All @@ -87,5 +87,5 @@ export const hydrateLineage = (
return { _id: parentId };
});
const hierarchy: NonEmptyArray<DataObject> = [contact, ...fullLineage];
return mergeLineage(hierarchy.slice(0, -1), getLastElement(hierarchy)) as Contact;
return mergeLineage(hierarchy.slice(0, -1), getLastElement(hierarchy)) as Contact.v1.Contact;
};
4 changes: 2 additions & 2 deletions shared-libs/cht-datasource/src/local/person.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import contactTypeUtils from '@medic/contact-types-utils';
import { deepCopy, isNonEmptyArray, Nullable, Page } from '../libs/core';
import { ContactTypeQualifier, UuidQualifier } from '../qualifier';
import * as Person from '../person';
import {fetchAndFilter, getDocById, getDocsByIds, queryDocsByKey} from './libs/doc';
import { fetchAndFilter, getDocById, getDocsByIds, queryDocsByKey } from './libs/doc';
import { LocalDataContext, SettingsService } from './libs/data-context';
import logger from '@medic/logger';
import { getLineageDocsById, getPrimaryContactIds, hydrateLineage, hydratePrimaryContact } from './libs/lineage';
import {InvalidArgumentError} from '../libs/error';
import { InvalidArgumentError } from '../libs/error';

/** @internal */
export namespace v1 {
Expand Down
6 changes: 3 additions & 3 deletions shared-libs/cht-datasource/src/local/place.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import contactTypeUtils from '@medic/contact-types-utils';
import { deepCopy, isNonEmptyArray, NonEmptyArray, Nullable, Page } from '../libs/core';
import { ContactTypeQualifier, UuidQualifier } from '../qualifier';
import * as Place from '../place';
import {fetchAndFilter, getDocById, getDocsByIds, queryDocsByKey} from './libs/doc';
import { fetchAndFilter, getDocById, getDocsByIds, queryDocsByKey } from './libs/doc';
import { LocalDataContext, SettingsService } from './libs/data-context';
import { Contact } from '../libs/contact';
import * as Contact from '../contact';
import logger from '@medic/logger';
import { getLineageDocsById, getPrimaryContactIds, hydrateLineage, hydratePrimaryContact } from './libs/lineage';
import { InvalidArgumentError } from '../libs/error';
Expand Down Expand Up @@ -56,7 +56,7 @@ export namespace v1 {
const contactUuids = getPrimaryContactIds(places);
const contacts = await getMedicDocsById(contactUuids);
const [placeWithContact, ...linagePlacesWithContact] = places.map(hydratePrimaryContact(contacts));
const placeWithLineage = hydrateLineage(placeWithContact as Contact, linagePlacesWithContact);
const placeWithLineage = hydrateLineage(placeWithContact as Contact.v1.Contact, linagePlacesWithContact);
return deepCopy(placeWithLineage);
};
};
Expand Down
Loading
Loading