Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
d4e0d80
feat(#10706): add bulk operation document model
vikrantwiz02 Jun 29, 2026
25f8e20
build(#10706): add @medic/bulk-operations to package-lock
vikrantwiz02 Jun 29, 2026
a088c6b
feat(#10706): add bulk operation status endpoint
vikrantwiz02 Jun 29, 2026
aa5a218
feat(#10706): add bulk operation queue helper
vikrantwiz02 Jul 4, 2026
aaa0546
feat(#10706): process bulk operations in sentinel
vikrantwiz02 Jul 4, 2026
50ed392
feat(#10706): add contact hierarchy delete endpoints
vikrantwiz02 Jul 4, 2026
77448a5
refactor(#10706): reduce delete-contact service complexity
vikrantwiz02 Jul 4, 2026
93d333c
test(#10706): cover the sentinel bulk-operation listener
vikrantwiz02 Jul 4, 2026
1526ef0
docs(#10706): use standard JSDoc types so the generated docs build pa…
vikrantwiz02 Jul 4, 2026
2b7b945
feat(#10706): declare the can_delete_contact_hierarchy permission
vikrantwiz02 Jul 4, 2026
b403287
Merge branch '10706-dmp-2026-add-api-endpoints-for-advanced-contact-m…
vikrantwiz02 Jul 6, 2026
a21c063
refactor(#10706): share the contact-delete handler and OpenAPI docs
vikrantwiz02 Jul 6, 2026
8428c7f
feat(#10706): enforce contact type on the delete endpoints
vikrantwiz02 Jul 6, 2026
3c454d1
refactor(#10706): address review feedback on the bulk-operation delete
vikrantwiz02 Jul 10, 2026
2a1b6ce
fix(#10706): resolve sonarcloud code smells in the bulk-operation act…
vikrantwiz02 Jul 10, 2026
1060de6
test(#10706): add integration tests for the contact deletion endpoints
vikrantwiz02 Jul 13, 2026
3361340
test(#10706): drop the archive-gated skipped tests flagged by sonarcloud
vikrantwiz02 Jul 13, 2026
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
79 changes: 79 additions & 0 deletions api/src/controllers/bulk-operations.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
const service = require('../services/bulk-operations');
const serverUtils = require('../server-utils');
const auth = require('../auth');

module.exports = {
v1: {
/**
* @openapi
* /api/v1/bulk-operations/{id}:
* get:
* summary: Get the status of a bulk operation
* operationId: v1BulkOperationIdGet
* description: >
* Returns the log document for a bulk operation, including the per-action status and the
* count of changes applied so far. Used to poll the progress of an operation that was
* started through one of the bulk endpoints.
* tags: [Bulk]
* x-since: 5.3.0
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* description: The id of the bulk operation, as returned when it was started.
* responses:
* '200':
* description: The bulk operation log
* content:
* application/json:
* schema:
* type: object
* properties:
* _id:
* type: string
* description: The bulk operation id.
* start_date:
* type: string
* format: date-time
* description: When the operation was started.
* actions:
* type: object

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If possible, can we also include the valid action properties here? E.g. each property on this actions record should have a status, etc.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, spelled out the per-action shape (status/action/updated_date/total_changes_count/failed_operations).

* description: Per-action status, keyed by action id.
* additionalProperties:
* type: object
* properties:
* status:
* type: string
* enum: [queued, completed, failed]
* action:
* type: string
* enum: [archive, set-contact, delete-user]
* updated_date:
* type: string
* format: date-time
* total_changes_count:
* type: integer
* failed_operations:
* type: array
* description: The operations that failed, present only when status is failed.
* items:
* type: object
* '401':
* $ref: '#/components/responses/Unauthorized'
* '403':
* $ref: '#/components/responses/Forbidden'
* '404':
* $ref: '#/components/responses/NotFound'
*/
get: serverUtils.doOrError(async (req, res) => {
await auth.assertPermissions(req, { isOnline: true });
const log = await service.getLog(req.params.id);
if (!log) {
return serverUtils.error({ status: 404, message: 'Bulk operation not found' }, req, res);
}
res.json(log);
})
}
};
44 changes: 44 additions & 0 deletions api/src/controllers/person.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const { Person, Qualifier } = require('@medic/cht-datasource');
const ctx = require('../services/data-context');
const serverUtils = require('../server-utils');
const auth = require('../auth');
const deleteContactService = require('../services/delete-contact');

const getPerson = ctx.bind(Person.v1.get);
const getPersonWithLineage = ctx.bind(Person.v1.getWithLineage);
Expand Down Expand Up @@ -208,5 +209,48 @@ module.exports = {
const updatedPersonDoc = await updatePerson(updatePersonInput);
return res.json(updatedPersonDoc);
}),

/**
* @openapi
* /api/v1/person/{id}:
* delete:
* summary: Delete a person
* operationId: v1PersonIdDelete
* description: >
* Queues an asynchronous bulk operation that removes the person and the reports they are the
* subject of, clears any dangling primary-contact references, and (with delete_users=true)
* removes linked user accounts. Returns a summary of the changes and the bulk operation id
* to poll.
* tags: [Person]
* x-since: 5.3.0
* x-permissions:
* hasAll: [can_delete_contact_hierarchy, can_delete_users]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* description: The id of the person to delete
* - $ref: '#/components/parameters/deleteUsers'
* - $ref: '#/components/parameters/dryRun'
* 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'
*/
delete: deleteContactService.handleDelete({
get: (uuid) => getPerson(Qualifier.byUuid(uuid)),
type: 'Person',
}),
},
};
44 changes: 44 additions & 0 deletions api/src/controllers/place.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const { Place, Qualifier } = require('@medic/cht-datasource');
const ctx = require('../services/data-context');
const serverUtils = require('../server-utils');
const auth = require('../auth');
const deleteContactService = require('../services/delete-contact');

const getPlace = ctx.bind(Place.v1.get);
const getPlaceWithLineage = ctx.bind(Place.v1.getWithLineage);
Expand Down Expand Up @@ -214,6 +215,49 @@ module.exports = {
};
const updatedPlaceDoc = await update(updatePlaceInput);
return res.json(updatedPlaceDoc);
}),

/**
* @openapi
* /api/v1/place/{id}:
* delete:
* summary: Delete a place and its hierarchy
* operationId: v1PlaceIdDelete
* description: >
* Queues an asynchronous bulk operation that removes the place, every descendant contact,
* and the reports they are the subject of, clears any dangling primary-contact references,
* and (with delete_users=true) removes linked user accounts. Returns a summary of the
* changes and the bulk operation id to poll.
* tags: [Place]
* x-since: 5.3.0
* x-permissions:
* hasAll: [can_delete_contact_hierarchy, can_delete_users]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: string
* description: The id of the place to delete
* - $ref: '#/components/parameters/deleteUsers'
* - $ref: '#/components/parameters/dryRun'
* 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'
*/
delete: deleteContactService.handleDelete({
get: (uuid) => getPlace(Qualifier.byUuid(uuid)),
type: 'Place',
})
}
};
5 changes: 5 additions & 0 deletions api/src/routing.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const { people, places } = require('@medic/contacts')(config, db, dataContext);
const upgrade = require('./controllers/upgrade');
const settings = require('./controllers/settings');
const bulkDocs = require('./controllers/bulk-docs');
const bulkOperations = require('./controllers/bulk-operations');
const monitoring = require('./controllers/monitoring');
const africasTalking = require('./controllers/africas-talking');
const rapidPro = require('./controllers/rapidpro');
Expand Down Expand Up @@ -476,6 +477,8 @@ app.get('/api/v1/monitoring', deprecation.deprecate('/api/v2/monitoring'), monit
app.get('/api/v2/monitoring', monitoring.getV2);
app.get('/api/v1/impact', impact.v1.get);

app.get('/api/v1/bulk-operations/:id', bulkOperations.v1.get);

app.post('/api/v1/upgrade', jsonParser, upgrade.upgrade);
app.post('/api/v1/upgrade/stage', jsonParser, upgrade.stage);
app.post('/api/v1/upgrade/complete', jsonParser, upgrade.complete);
Expand Down Expand Up @@ -659,6 +662,7 @@ app.get('/api/v1/place', place.v1.getAll);
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);

/**
* @openapi
Expand Down Expand Up @@ -734,6 +738,7 @@ app.get('/api/v1/person', person.v1.getAll);
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.get('/api/v1/contact/uuid', contact.v1.getUuids);
app.postJson('/api/v1/contact/summary', contact.v1.getSummaries);
Expand Down
95 changes: 95 additions & 0 deletions api/src/services/bulk-operations.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
const { v7: uuid } = require('uuid');
const db = require('../db');
const { BULK_OPERATIONS } = require('@medic/constants');

const { LOG_ID_PREFIX, ACTION_ID_PREFIX, OPERATIONS_ATTACHMENT, STATUSES } = BULK_OPERATIONS;
const OPERATIONS_CONTENT_TYPE = 'application/json';

const getOperationUuid = (operationId) => operationId.slice(LOG_ID_PREFIX.length);
const generateOperationId = () => `${LOG_ID_PREFIX}${uuid()}`;
const generateActionId = (operationId) => `${ACTION_ID_PREFIX}${getOperationUuid(operationId)}:${uuid()}`;

// Params live in an attachment so advancing the cursor as Sentinel batches does not rewrite them.
const encodeOperations = (operations) => ({
content_type: OPERATIONS_CONTENT_TYPE,
data: Buffer.from(JSON.stringify(operations)).toString('base64'),
});

const buildActionDoc = (operationId, action, operations) => ({
_id: generateActionId(operationId),
bulk_operation_id: operationId,
action,
cursor: 0,
total: operations.length,
_attachments: {
[OPERATIONS_ATTACHMENT]: encodeOperations(operations),
},
});

const buildLogAction = (action, totalChangesCount, date) => ({
status: STATUSES.QUEUED,
action,
updated_date: date,
total_changes_count: totalChangesCount,
});

const buildBulkOperation = (actionOperations, date) => {
const operationId = generateOperationId();
const actions = actionOperations.map(({ action, operations }) => buildActionDoc(operationId, action, operations));

const logActions = {};
actions.forEach((actionDoc) => {
logActions[actionDoc._id] = buildLogAction(actionDoc.action, actionDoc.total, date);
});

const log = {
_id: operationId,
start_date: date,
actions: logActions,
};

return { log, actions };
};

// Guards against returning the other kinds of log doc that share the medic-logs database.
const getLog = async (id) => {
if (!id?.startsWith(LOG_ID_PREFIX)) {
return null;
}

try {
const log = await db.medicLogs.get(id);
delete log._rev;
return log;
} catch (err) {
if (err.status === 404) {
return null;
}
throw err;
}
};

/**
* Queues a bulk operation: writes the log document to medic-logs (the status record the polling
* endpoint reads) and one action document per action type to medic-sentinel (what the Sentinel
* listener processes). The log is written first so it exists before the listener picks up an action.
* Action groups with no operations are skipped.
* @param {Object[]} actionOperations - one group per action type
* @param {string} actionOperations[].action - the action type (`archive`, `set-contact`, `delete-user`)
* @param {Object[]} actionOperations[].operations - the per-item params for that action
* @returns {Promise<string>} the bulk operation id
*/
const queue = async (actionOperations) => {
const nonEmpty = actionOperations.filter(({ operations }) => operations.length);
const { log, actions } = buildBulkOperation(nonEmpty, new Date());

await db.medicLogs.put(log);
await db.sentinel.bulkDocs(actions);

return log._id;
};

module.exports = {
getLog,
queue,
};
Loading