Skip to content
Open
2 changes: 1 addition & 1 deletion api/src/controllers/bulk-operations.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ module.exports = {
* enum: [queued, completed, failed]
* action:
* type: string
* enum: [archive, set-contact, delete-user]
* enum: [archive, set-contact, set-parent, delete-user]
* updated_date:
* type: string
* format: date-time
Expand Down
56 changes: 56 additions & 0 deletions api/src/controllers/person.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const ctx = require('../services/data-context');
const serverUtils = require('../server-utils');
const auth = require('../auth');
const deleteContactService = require('../services/delete-contact');
const moveContactService = require('../services/move-contact');

const getPerson = ctx.bind(Person.v1.get);
const getPersonWithLineage = ctx.bind(Person.v1.getWithLineage);
Expand Down Expand Up @@ -252,5 +253,60 @@ module.exports = {
get: (uuid) => getPerson(Qualifier.byUuid(uuid)),
type: 'Person',
}),

/**
* @openapi
* /api/v1/person/{id}/move:
* post:
* summary: Move a person to a new parent
* operationId: v1PersonIdMovePost
* description: >
* Queues an asynchronous bulk operation that moves the person and its whole subtree under a
* new parent, rewriting the parent lineage on every descendant and refreshing the cached
* lineage on their reports and on any place whose primary contact moved. Returns a summary
* of the changes and the bulk operation id to poll.
* tags: [Person]
* x-since: 5.3.0
* x-permissions:
* hasAll: [can_move_contact_hierarchy]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* description: The id of the person to move
* - $ref: '#/components/parameters/dryRun'
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [parent_id]
* properties:
* parent_id:
* type: string
* description: >
* The id of the destination parent, or `root` to move the contact to the top level.
* responses:
* '202':
* $ref: '#/components/responses/BulkOperationQueued'
* '200':
* $ref: '#/components/responses/BulkOperationDryRun'
* '400':
* $ref: '#/components/responses/BadRequest'
* '401':
* $ref: '#/components/responses/Unauthorized'
* '403':
* $ref: '#/components/responses/Forbidden'
* '404':
* $ref: '#/components/responses/NotFound'
*/
move: moveContactService.handleMove({
get: (uuid) => getPerson(Qualifier.byUuid(uuid)),
type: 'Person',
}),

},
};
57 changes: 56 additions & 1 deletion api/src/controllers/place.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const ctx = require('../services/data-context');
const serverUtils = require('../server-utils');
const auth = require('../auth');
const deleteContactService = require('../services/delete-contact');
const moveContactService = require('../services/move-contact');

const getPlace = ctx.bind(Place.v1.get);
const getPlaceWithLineage = ctx.bind(Place.v1.getWithLineage);
Expand Down Expand Up @@ -258,6 +259,60 @@ module.exports = {
delete: deleteContactService.handleDelete({
get: (uuid) => getPlace(Qualifier.byUuid(uuid)),
type: 'Place',
})
}),

/**
* @openapi
* /api/v1/place/{id}/move:
* post:
* summary: Move a place to a new parent
* operationId: v1PlaceIdMovePost
* description: >
* Queues an asynchronous bulk operation that moves the place and its whole subtree under a
* new parent, rewriting the parent lineage on every descendant and refreshing the cached
* lineage on their reports and on any place whose primary contact moved. Returns a summary
* of the changes and the bulk operation id to poll.
* tags: [Place]
* x-since: 5.3.0
* x-permissions:
* hasAll: [can_move_contact_hierarchy]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* description: The id of the place to move
* - $ref: '#/components/parameters/dryRun'
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [parent_id]
* properties:
* parent_id:
* type: string
* description: >
* The id of the destination parent, or `root` to move the place to the top level.
* responses:
* '202':
* $ref: '#/components/responses/BulkOperationQueued'
* '200':
* $ref: '#/components/responses/BulkOperationDryRun'
* '400':
* $ref: '#/components/responses/BadRequest'
* '401':
* $ref: '#/components/responses/Unauthorized'
* '403':
* $ref: '#/components/responses/Forbidden'
* '404':
* $ref: '#/components/responses/NotFound'
*/
move: moveContactService.handleMove({
get: (uuid) => getPlace(Qualifier.byUuid(uuid)),
type: 'Place',
}),
}
};
2 changes: 2 additions & 0 deletions api/src/routing.js
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,7 @@ app.get('/api/v1/place/:uuid', place.v1.get);
app.postJson('/api/v1/place', place.v1.create);
app.putJson('/api/v1/place/:uuid', place.v1.update);
app.delete('/api/v1/place/:uuid', place.v1.delete);
app.postJson('/api/v1/place/:uuid/move', place.v1.move);

/**
* @openapi
Expand Down Expand Up @@ -740,6 +741,7 @@ app.get('/api/v1/person/:uuid', person.v1.get);
app.postJson('/api/v1/person', person.v1.create);
app.putJson('/api/v1/person/:uuid', person.v1.update);
app.delete('/api/v1/person/:uuid', person.v1.delete);
app.postJson('/api/v1/person/:uuid/move', person.v1.move);

app.get('/api/v1/contact', contact.v1.getAll);
app.get('/api/v1/contact/uuid', contact.v1.getUuids);
Expand Down
144 changes: 144 additions & 0 deletions api/src/services/hierarchy/lineage-constraints.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/**
* Legality checks for hierarchy operations.
*
* Ported from cht-conf `src/lib/hierarchy-operations/lineage-constraints.js`, with two changes.
* cht-conf reads `contact_types` out of the settings doc and falls back to a hardcoded default;
* server-side we already have the parsed configuration, so `@medic/contact-types-utils` does that
* job. And cht-conf moves a list of contacts in one command, so it additionally rejects two sources
* from the same lineage; a move here has a single source, so that check does not apply.
*/

const db = require('../../db');
const config = require('../../config');
const contactTypesUtils = require('@medic/contact-types-utils');
const { pluckIdsFromLineage } = require('./lineage-manipulation');
const { BadRequestError } = require('../../errors');

const getPrimaryContactId = (doc) => typeof doc?.contact === 'string' ? doc.contact : doc?.contact?._id;

/**
* A contact may only be placed under a parent its configured type permits, and a type with no
* configured parents may only sit at the root.
*/
const assertRootIsAllowed = (sourceType) => {
if (contactTypesUtils.hasParents(sourceType)) {
throw new BadRequestError(`contacts of type '${sourceType.id}' cannot be moved to the root`);
}
};

const assertParentTypeIsAllowed = (settings, sourceDoc, destinationDoc) => {
const sourceType = contactTypesUtils.getContactType(settings, sourceDoc);
if (!sourceType) {
throw new BadRequestError(`cannot move contact with unknown type '${contactTypesUtils.getTypeId(sourceDoc)}'`);
}

if (!destinationDoc) {
return assertRootIsAllowed(sourceType);
}

const destinationType = contactTypesUtils.getContactType(settings, destinationDoc);
if (!destinationType) {
throw new BadRequestError(`destination contact '${destinationDoc._id}' has an unknown type`);
}

if (!contactTypesUtils.isParentOf(destinationType, sourceType)) {
throw new BadRequestError(`contacts of type '${sourceType.id}' cannot have parent of type '${destinationType.id}'`);
}
};

/**
* Moving a contact beneath one of its own descendants would detach the subtree into a loop.
*/
const assertDestinationIsNotCurrentParent = (sourceDoc, destinationDoc) => {
const currentParentId = sourceDoc.parent?._id || sourceDoc.parent;
if ((destinationDoc?._id || null) === (currentParentId || null)) {
throw new BadRequestError(`contact '${sourceDoc._id}' already has that parent`);
}
};

const assertNoCircularHierarchy = (sourceDoc, destinationDoc) => {
if (!destinationDoc) {
return;
}

if (sourceDoc._id === destinationDoc._id) {
throw new BadRequestError('cannot move a contact to itself');
}

const destinationAncestry = pluckIdsFromLineage(destinationDoc);
if (destinationAncestry.includes(sourceDoc._id)) {
throw new BadRequestError(
`circular hierarchy: '${destinationDoc._id}' is a descendant of '${sourceDoc._id}'`
);
}
};

/**
* A place's primary contact must live beneath that place. Any ancestor dropping out of the source's
* lineage as a result of the move must therefore not have a primary contact inside the moved subtree.
*/
const assertNoPrimaryContactStranded = async (sourceDoc, destinationDoc, descendantIds) => {
const sourceLineageIds = pluckIdsFromLineage(sourceDoc.parent);
const destinationLineageIds = pluckIdsFromLineage(destinationDoc);
const leavingLineage = sourceLineageIds.filter(id => !destinationLineageIds.includes(id));
if (!leavingLineage.length) {
return;
}

const result = await db.medic.allDocs({ keys: leavingLineage, include_docs: true });
const moved = new Set(descendantIds);
const stranded = result.rows
.map(row => ({ place: row.doc, contactId: getPrimaryContactId(row.doc) }))
.find(({ contactId }) => contactId && moved.has(contactId));

if (stranded) {
throw new BadRequestError(
`cannot move '${sourceDoc._id}': it would strand the primary contact of '${stranded.place._id}'`
);
}
};

/**
* The source's own primary contact must be a person, never a place.
*/
const assertSourcePrimaryContactIsPerson = async (settings, sourceDoc) => {
const primaryContactId = getPrimaryContactId(sourceDoc);
if (!primaryContactId) {
return;
}

const primaryContact = await db.medic.get(primaryContactId).catch(err => {
if (err.status === 404) {
return null;
}
throw err;
});

if (primaryContact && !contactTypesUtils.isPerson(settings, primaryContact)) {
throw new BadRequestError(
`contact '${sourceDoc._id}' has a primary contact '${primaryContactId}' which is not a person`
);
}
};

/**
* Runs every legality check for a move. Throws on the first violation; the caller turns that into a
* `BadRequestError`, so any other failure (a database error, say) propagates as a 500 instead of
* being reported to the caller as an invalid move.
* @param {Object} sourceDoc - the contact being moved
* @param {Object|null} destinationDoc - the new parent, or null when moving to the root
* @param {string[]} descendantIds - the ids of the source and everything beneath it
* @throws {BadRequestError} when the move would be illegal
*/
const assertMoveIsLegal = async (sourceDoc, destinationDoc, descendantIds) => {
const settings = config.getAll();
assertDestinationIsNotCurrentParent(sourceDoc, destinationDoc);
assertNoCircularHierarchy(sourceDoc, destinationDoc);
assertParentTypeIsAllowed(settings, sourceDoc, destinationDoc);
await assertNoPrimaryContactStranded(sourceDoc, destinationDoc, descendantIds);
await assertSourcePrimaryContactIsPerson(settings, sourceDoc);
};

module.exports = {
assertMoveIsLegal,
};
40 changes: 40 additions & 0 deletions api/src/services/hierarchy/lineage-manipulation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Lineage manipulation helpers for hierarchy operations (move/merge).
*
* Ported from cht-conf `src/lib/hierarchy-operations/lineage-manipulation.js`, minus its
* `minifyLineagesInDoc`: that function duplicates `@medic/lineage`'s `minify`, which is available
* server-side, so callers should use `require('@medic/lineage')(Promise, db).minify` instead of
* porting a copy. The remaining helpers (`createLineageFromDoc`, `pluckIdsFromLineage`) and the
* re-exported `replace-lineage` functions have no shared-lib equivalent and are ported here.
*/

const { replaceContactLineage, replaceParentLineage } = require('./replace-lineage');

const createLineageFromDoc = doc => {
if (!doc) {
return undefined;
}

return {
_id: doc._id,
parent: doc.parent || undefined,
};
};

/*
Given a lineage, return the ids therein
*/
const pluckIdsFromLineage = (lineage, results = []) => {
if (!lineage) {
return results;
}

return pluckIdsFromLineage(lineage.parent, [...results, lineage._id]);
};

module.exports = {
createLineageFromDoc,
pluckIdsFromLineage,
replaceParentLineage,
replaceContactLineage,
};
Loading
Loading