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 15 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
52 changes: 52 additions & 0 deletions api/src/controllers/contact.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
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 getContactIds = () => ctx.bind(Contact.v1.getIdsPage);

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);
}),
getIds: serverUtils.doOrError(async (req, res) => {
await checkUserPermissions(req);

if (!req.query.freetext && !req.query.type) {
return serverUtils.error({ status: 400, message: 'Either query param freetext or type is required' }, req, res);
}
const qualifier = {};

if (req.query.freetext) {
Object.assign(qualifier, Qualifier.byFreetext(req.query.freetext));
}

if (req.query.type) {
Object.assign(qualifier, Qualifier.byContactType(req.query.type));
}

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

const docs = await getContactIds()(qualifier, req.query.cursor, limit);

return res.json(docs);
}),
},
};
40 changes: 40 additions & 0 deletions api/src/controllers/report.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
const auth = require('../auth');
const ctx = require('../services/data-context');
const serverUtils = require('../server-utils');
const { Report, Qualifier } = require('@medic/cht-datasource');

const getReport = () => ctx.bind(Report.v1.get);
const getReportIds = () => ctx.bind(Report.v1.getIdsPage);

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 report = await getReport()(Qualifier.byUuid(uuid));

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

return res.json(report);
}),
getIds: serverUtils.doOrError(async (req, res) => {
await checkUserPermissions(req);

const qualifier = Qualifier.byFreetext(req.query.freetext);
const limit = req.query.limit ? Number(req.query.limit) : req.query.limit;

const docs = await getReportIds()(qualifier, req.query.cursor, limit);

return res.json(docs);
})
}
};
8 changes: 8 additions & 0 deletions api/src/routing.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,10 @@ 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 report = require('./controllers/report');
const { people, places } = require('@medic/contacts')(config, db, dataContext);
const upgrade = require('./controllers/upgrade');
const settings = require('./controllers/settings');
Expand Down Expand Up @@ -492,6 +494,12 @@ 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/id', contact.v1.getIds);
app.get('/api/v1/contact/:uuid', contact.v1.get);

app.get('/api/v1/report/id', report.v1.getIds);
app.get('/api/v1/report/:uuid', report.v1.get);

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

// offline users are not allowed to hydrate documents via the hydrate API
Expand Down
209 changes: 209 additions & 0 deletions shared-libs/cht-datasource/src/contact.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import { Doc } from './libs/doc';
import {
assertCursor,
assertFreetextQualifier,
assertLimit,
assertTypeQualifier,
DataObject,
getPagedGenerator,
Identifiable,
isDataObject,
isIdentifiable,
Nullable,
Page,
} from './libs/core';
import {
byContactType,
byFreetext,
ContactTypeQualifier,
FreetextQualifier,
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;
}

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

/** @ignore */
export const isContactType = (value: ContactTypeQualifier | FreetextQualifier): value is ContactTypeQualifier => {
return 'contactType' in value;
};

/** @ignore */
export const isFreetextType = (value: ContactTypeQualifier | FreetextQualifier): value is FreetextQualifier => {
return 'freetext' in value;
};
/**
* 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)}].`);
}
};

/** @ignore */
export const createQualifier = (
freetext: Nullable<string> = null,
type: Nullable<string> = null
): ContactTypeQualifier | FreetextQualifier => {
if (!freetext && !type) {
throw new InvalidArgumentError('Either "freetext" or "type" is required');
}

const qualifier = {};
if (freetext) {
Object.assign(qualifier, byFreetext(freetext));
}

if (type) {
Object.assign(qualifier, byContactType(type));
}

return qualifier as ContactTypeQualifier | FreetextQualifier;
};

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);

/**
* 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
*/
export const getIdsPage = (context: DataContext): typeof curriedFn => {
assertDataContext(context);
const fn = adapt(context, Local.Contact.v1.getPage, Remote.Contact.v1.getPage);

/**
* 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 InvalidArrgumentError if no qualifier is provided or if the qualifier is invalid
* @throws InvalidArgumentError if the provided `limit` value is `<=0`
* @throws InvalidArgumentError if the provided cursor is not a valid page token or `null`
*/
const curriedFn = async (
qualifier: ContactTypeQualifier | FreetextQualifier,
cursor: Nullable<string> = null,
limit = 100
): Promise<Page<string>> => {
assertCursor(cursor);
assertLimit(limit);

if (isContactType(qualifier)) {
assertTypeQualifier(qualifier);
}

if (isFreetextType(qualifier)) {
assertFreetextQualifier(qualifier);
}

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

/**
* 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
*/
export const getIdsAll = (context: DataContext): typeof curriedGen => {
assertDataContext(context);
const getPage = context.bind(v1.getIdsPage);

/**
* 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 InvalidArgumentError if no qualifier is provided or if the qualifier is invalid
*/
const curriedGen = (
qualifier: ContactTypeQualifier | FreetextQualifier
): AsyncGenerator<string, null> => {
if (isContactType(qualifier)) {
assertTypeQualifier(qualifier);
}

if (isFreetextType(qualifier)) {
assertFreetextQualifier(qualifier);
}
return getPagedGenerator(getPage, qualifier);
};
return curriedGen;
};
}
Loading
Loading