Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
9b3f59a
Introduce `archiving` to scheduled tasks
dianabarsan May 21, 2026
2239d4d
Expand archiving functionality: introduce `archive` DB, adjust sync t…
dianabarsan May 21, 2026
f25d915
Refactor archiving: extract reusable utilities, enhance error handlin…
dianabarsan May 22, 2026
866021c
Enhance archiving: add error recording with capped history, fallback …
dianabarsan May 22, 2026
2f770ec
Refine error recording: remove `errorMessage` helper, simplify error …
dianabarsan May 22, 2026
681f254
Refine archiving tests: improve error matching, adjust sentinel log h…
dianabarsan May 25, 2026
a6b9cff
Expand archiving tests: verify archive entries, handle mixed doc stat…
dianabarsan May 25, 2026
1705f5a
Expand archiving tests: add archive DB support, improve replication t…
dianabarsan May 26, 2026
d5569b8
add sentinel test coverage
dianabarsan May 26, 2026
1fc4a73
Merge remote-tracking branch 'origin/master' into 6615-cold-storage
dianabarsan May 27, 2026
7a2ff8f
Merge remote-tracking branch 'origin/master' into 6615-cold-storage
dianabarsan May 27, 2026
64c3ba4
add sentinel test coverage
dianabarsan May 27, 2026
3f6814a
Merge remote-tracking branch 'origin/master' into 6615-cold-storage
dianabarsan Jun 4, 2026
6600cf2
appease the sonar gods
dianabarsan Jun 4, 2026
b29173f
Expand archiving tests: add cleanup for archive jobs, refine assertio…
dianabarsan Jun 4, 2026
e000660
add a bunch of debug logs
dianabarsan Jun 5, 2026
d311b3e
be defensive for tests
dianabarsan Jun 5, 2026
22a7bf9
Add delay in archiving test to ensure proper document processing.
dianabarsan Jun 5, 2026
ce617fd
Add delay in archiving test to ensure proper document processing.
dianabarsan Jun 5, 2026
3a1290e
Add delay in archiving test to ensure proper document processing.
dianabarsan Jun 5, 2026
8552c43
Simplify archiving test logic: remove unused skipTransitions flag, ad…
dianabarsan Jun 5, 2026
35aaabd
Refactor archiving tests: centralize updateSettings logic, adjust pay…
dianabarsan Jun 10, 2026
c10445e
Merge branch 'master' into 6615-cold-storage
dianabarsan Jun 23, 2026
fe9687b
Add trailing comma to PREFIXES constant for consistency.
dianabarsan Jun 24, 2026
f086695
Refine archiving tests: replace fixed delay with waitForInfoDocs util…
dianabarsan Jun 24, 2026
e11140f
appease the sonar gods.
dianabarsan Jun 24, 2026
885d6f2
Fine-tune archiving test: adjust deadline to 5ms for tighter task exe…
dianabarsan Jun 24, 2026
54df1bf
Adjust archiving test: increase updateSettings delay to 20ms for stea…
dianabarsan Jun 24, 2026
cade423
Revise `processQueue` logic: switch from `while` to `do-while` for gu…
dianabarsan Jun 24, 2026
e3111e8
Refine `archiveJob` flow: transition from `while` to `do-while` for a…
dianabarsan Jun 25, 2026
784644d
Merge branch 'master' into 6615-cold-storage
dianabarsan Jun 26, 2026
da3fe72
Refactor archiving logic and tests: adjust purge order, prevent dupli…
dianabarsan Jun 26, 2026
796a182
quarantine failed jobs after max attempts and enhance archiving flow
dianabarsan Jun 26, 2026
cc1e0ff
Merge remote-tracking branch 'origin/6615-cold-storage' into 10706-dm…
jkuester Jul 22, 2026
4b4caf2
Merge branch 'master' into 10706-dmp-2026-add-api-endpoints-for-advan…
sugat009 Jul 22, 2026
a3bd5cf
feat(#10706): add bulk operation framework and contact hierarchy dele…
vikrantwiz02 Jul 24, 2026
4941f2e
Merge branch 'master' into 10706-dmp-2026-add-api-endpoints-for-advan…
sugat009 Aug 3, 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
101 changes: 101 additions & 0 deletions api/src/controllers/archive.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
const readline = require('node:readline');
const { v7: uuid } = require('uuid');
const logger = require('@medic/logger');
const archivingUtils = require('@medic/archiving-utils');
const constants = require('@medic/constants');

const db = require('../db');
const auth = require('../auth');
const serverUtils = require('../server-utils');
const errors = require('../errors');

const MAX_IDS_PER_JOB = 100 * 1000;
const EXPECTED_CONTENT_TYPE = 'text/csv';
const parseCell = (line) => line.trim().replace(/^"(.*)"$/, '$1');

const checkAdmin = async (req) => {
const userCtx = await auth.getUserCtx(req);
if (!auth.isDbAdmin(userCtx)) {
throw new errors.AuthenticationError('User is not an admin');
}
};

const checkContentType = (req) => {
if (!req.is(EXPECTED_CONTENT_TYPE)) {
throw new errors.ContentTypeError(`Content-Type must be ${EXPECTED_CONTENT_TYPE}`);
}
};

const buildJobId = () => `${constants.PREFIXES.ARCHIVE_JOB}${uuid()}`;

const persistJob = async (jobs, ids) => {
if (!ids.length) {
return;
}

const doc = {
_id: buildJobId(),
type: constants.PREFIXES.ARCHIVE_JOB,
date: Date.now(),
total: ids.length,
cursor: 0,
_attachments: {
[archivingUtils.ATTACHMENT_NAME]: {
content_type: archivingUtils.ATTACHMENT_TYPE,
data: archivingUtils.encodeIds(ids),
},
},
};

await db.sentinel.put(doc);
jobs.push({ id: doc._id, count: doc.total });
};

const flushIfFull = async (jobs, buffer) => {
if (buffer.length < MAX_IDS_PER_JOB) {
return buffer;
}
await persistJob(jobs, buffer);
return [];
};

const processPayload = async (req) => {
const jobs = [];
let buffer = [];
const rl = readline.createInterface({ input: req, crlfDelay: Infinity });

for await (const line of rl) {
const id = parseCell(line);
if (!id) {
continue;
}
buffer.push(id);
buffer = await flushIfFull(jobs, buffer);
}

await persistJob(jobs, buffer);

if (!jobs.length) {
throw new errors.BadRequestError('No valid doc IDs found in request body');
}
return jobs;
};

module.exports = {
create: async (req, res) => {
try {
await checkAdmin(req);
checkContentType(req);
} catch (err) {
return serverUtils.error(err, req, res);
}

try {
const jobs = await processPayload(req);
res.status(201).json({ jobs });
} catch (err) {
logger.error('Failed to create archive jobs: %o', err);
return serverUtils.error(err, req, res);
}
},
};
80 changes: 80 additions & 0 deletions api/src/controllers/bulk-operations.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
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. The bulk operation can be considered finished
* when all of its actions have a status of "completed" or "failed".
* 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
* 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',
})
}
};
2 changes: 2 additions & 0 deletions api/src/db.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ if (UNIT_TEST_ENV) {
'builds',
'vault',
'cache',
'archive',
];
const DB_FUNCTIONS_TO_STUB = [
'allDocs',
Expand Down Expand Up @@ -103,6 +104,7 @@ if (UNIT_TEST_ENV) {
module.exports.vault = new PouchDB(`${environment.couchUrl}-vault`, { fetch: fetchFn });
module.exports.createVault = () => module.exports.vault.info();
module.exports.users = new PouchDB(getDbUrl('_users'), { fetch: fetchFn });
module.exports.archive = new PouchDB(`${environment.couchUrl}-archive`, { fetch: fetchFn });
module.exports.builds = new PouchDB(environment.buildsUrl);

// Get the DB with the given name
Expand Down
17 changes: 17 additions & 0 deletions api/src/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,26 @@ class AuthenticationError extends Error {
}
}

class ContentTypeError extends Error {
constructor(message, ...args) {
super(message, ...args);
this.code = 415;
}
}

class BadRequestError extends Error {
constructor(message, ...args) {
super(message, ...args);
this.code = 400;
}
}


module.exports = {
PublicError,
NotFoundError,
PermissionError,
AuthenticationError,
ContentTypeError,
BadRequestError,
};
13 changes: 13 additions & 0 deletions api/src/routing.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,15 @@ 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');
const infodoc = require('./controllers/infodoc');
const impact = require('./controllers/impact');
const targetController = require('./controllers/target');
const credentials = require('./controllers/credentials');
const archive = require('./controllers/archive');
const authorization = require('./middleware/authorization');
const deprecation = require('./middleware/deprecation');
const hydration = require('./controllers/hydration');
Expand Down Expand Up @@ -476,6 +478,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 +663,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 +739,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', contact.v1.getAll);
app.get('/api/v1/contact/uuid', contact.v1.getUuids);
Expand Down Expand Up @@ -796,6 +802,13 @@ app.put(
credentials.put
);

app.post(
'/api/v1/archive',
authorization.handleAuthErrors,
authorization.offlineUserFirewall,
archive.create
);

app.get('/api/v1/users-doc-count', replicationLimitLogController.get);
app.get('/api/v1/replication-failure-logs', replicationFailureLogController.get);
app.get('/api/v1/replication-health/failed', replicationHealthController.failed);
Expand Down
Loading
Loading