cht-conf 4.1.3 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/package.json +1 -1
- package/src/fn/edit-contacts.js +70 -50
- package/src/fn/merge-contacts.js +69 -0
- package/src/fn/move-contacts.js +13 -236
- package/src/lib/hierarchy-operations/hierarchy-data-source.js +103 -0
- package/src/lib/hierarchy-operations/index.js +186 -0
- package/src/lib/hierarchy-operations/jsdocFolder.js +37 -0
- package/src/lib/hierarchy-operations/lineage-constraints.js +170 -0
- package/src/lib/hierarchy-operations/lineage-manipulation.js +63 -0
- package/src/lib/hierarchy-operations/replace-lineage.js +76 -0
- package/src/lib/lineage-constraints.js +0 -94
- package/src/lib/lineage-manipulation.js +0 -107
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# [4.2.0](https://github.com/medic/cht-conf/compare/v4.1.3...v4.2.0) (2024-12-11)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* **#373:** merge-contacts action ([#647](https://github.com/medic/cht-conf/issues/647)) ([006554c](https://github.com/medic/cht-conf/commit/006554cf3a414d9577e05c6dfaef3bbd59849403)), closes [#373](https://github.com/medic/cht-conf/issues/373)
|
|
7
|
+
|
|
1
8
|
## [4.1.3](https://github.com/medic/cht-conf/compare/v4.1.2...v4.1.3) (2024-11-13)
|
|
2
9
|
|
|
3
10
|
|
package/package.json
CHANGED
package/src/fn/edit-contacts.js
CHANGED
|
@@ -105,69 +105,89 @@ function getIDs(csv, docType) {
|
|
|
105
105
|
return rows.map((item) => idPrefix + item[index]);
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
function processDocs(docType, csv, documentDocs, args) {
|
|
109
|
-
const { rows, cols } = fs.readCsv(csv);
|
|
110
|
-
const uuidIndex = cols.indexOf(DOCUMENT_ID);
|
|
111
|
-
const colNames = args.colNames;
|
|
112
|
-
let toIncludeColumns, toIncludeIndex;
|
|
113
|
-
if (!colNames.length) {
|
|
114
|
-
warn(' No columns specified, the script will add all the columns in the CSV!');
|
|
115
|
-
toIncludeColumns = cols;
|
|
116
|
-
toIncludeIndex = [];
|
|
117
|
-
|
|
118
|
-
} else {
|
|
119
|
-
if (!columnsAreValid(cols,colNames)) {
|
|
120
|
-
throw Error('The column name(s) specified do not exist.');
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
toIncludeColumns = cols.filter(column => colNames.includes(column.split(':')[0]));
|
|
124
|
-
toIncludeIndex = toIncludeColumns.map(column => cols.indexOf(column));
|
|
125
|
-
|
|
126
|
-
if (toIncludeColumns.includes(DOCUMENT_ID)) {
|
|
127
|
-
toIncludeIndex.splice(toIncludeColumns.indexOf(DOCUMENT_ID),1);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
if (toIncludeColumns.includes(DOCUMENT_ID)) {
|
|
132
|
-
toIncludeColumns.splice(toIncludeColumns.indexOf(DOCUMENT_ID),1);
|
|
133
|
-
}
|
|
134
|
-
return rows
|
|
135
|
-
.map(r => processCsv(docType, toIncludeColumns, r, uuidIndex, toIncludeIndex, documentDocs));
|
|
136
|
-
}
|
|
137
|
-
|
|
138
108
|
function columnsAreValid(csvColumns, toIncludeColumns) {
|
|
139
109
|
const splitCsvColumns = csvColumns.map(column => column && column.split(':')[0]);
|
|
140
110
|
return toIncludeColumns.every(column => splitCsvColumns.includes(column));
|
|
141
111
|
}
|
|
112
|
+
const parseColName = colName => colName.split(':')[0];
|
|
113
|
+
const getColNamesToInclude = (csvColNames, targetColNames) => {
|
|
114
|
+
const filteredCsvColNames = csvColNames.filter(colName => colName !== DOCUMENT_ID);
|
|
115
|
+
if (!targetColNames.length) {
|
|
116
|
+
warn(' No columns specified, the script will add all the columns in the CSV!');
|
|
117
|
+
return filteredCsvColNames;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!columnsAreValid(filteredCsvColNames, targetColNames)) {
|
|
121
|
+
throw Error('The column name(s) specified do not exist.');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return filteredCsvColNames.filter(column => targetColNames.includes(parseColName(column)));
|
|
125
|
+
};
|
|
142
126
|
|
|
143
|
-
|
|
127
|
+
const getCsvColNameIndex = csvColNames => colName => csvColNames.indexOf(colName);
|
|
128
|
+
const mapColNamesToCsvIndex = (csvColNames, toIncludeColumns) => toIncludeColumns.map(getCsvColNameIndex(csvColNames));
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
const getDocIdForCsvRow = (docType, uuidIndex) => row => {
|
|
144
132
|
const documentId = row[uuidIndex];
|
|
145
133
|
const idPrefix = docType === 'contact' ? '' : 'org.couchdb.user:';
|
|
146
|
-
|
|
134
|
+
return `${idPrefix}${documentId}`;
|
|
135
|
+
};
|
|
147
136
|
|
|
137
|
+
const getCsvRowFilterFn = (uuidIndex, toIncludeIndex) => {
|
|
148
138
|
if (toIncludeIndex.length > 0) {
|
|
149
|
-
row
|
|
150
|
-
} else {
|
|
151
|
-
row.splice(uuidIndex,1);
|
|
139
|
+
return row => toIncludeIndex.map(index => row[index]);
|
|
152
140
|
}
|
|
153
141
|
|
|
154
|
-
|
|
155
|
-
const
|
|
142
|
+
return row => {
|
|
143
|
+
const updatedRow = [...row];
|
|
144
|
+
updatedRow.splice(uuidIndex, 1);
|
|
145
|
+
return updatedRow;
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const isColNameProtected = col => EDIT_RESERVED_COL_NAMES.includes(col.split('.')[0]);
|
|
150
|
+
const processCsvColumn = (doc, row) => (colName, index) => {
|
|
151
|
+
const { col, val, excluded } = toDocs.parseColumn(colName, row[index]);
|
|
156
152
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
153
|
+
if (isColNameProtected(col)) {
|
|
154
|
+
throw new Error(`Cannot set property defined by column '${col}' - this property name is protected.`);
|
|
155
|
+
}
|
|
160
156
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
157
|
+
toDocs.setCol(doc, col, val);
|
|
158
|
+
if (excluded) {
|
|
159
|
+
model.exclusions.push({
|
|
160
|
+
doc,
|
|
161
|
+
propertyName: col,
|
|
162
|
+
});
|
|
168
163
|
}
|
|
169
|
-
|
|
170
|
-
|
|
164
|
+
};
|
|
165
|
+
const processCsvRow = colNames => ([doc, row]) => colNames.forEach(processCsvColumn(doc, row));
|
|
166
|
+
|
|
167
|
+
const getColIndexesToInclude = (colNames, cols, toIncludeColumns) => {
|
|
168
|
+
if(colNames.length) {
|
|
169
|
+
return mapColNamesToCsvIndex(cols, toIncludeColumns);
|
|
170
|
+
}
|
|
171
|
+
return [];
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const processDocs = (docType, csv, documentDocs, args) => {
|
|
175
|
+
const { rows, cols } = fs.readCsv(csv);
|
|
176
|
+
const uuidIndex = cols.indexOf(DOCUMENT_ID);
|
|
177
|
+
const colNamesToInclude = getColNamesToInclude(cols, args.colNames);
|
|
178
|
+
const colIndexesToInclude = getColIndexesToInclude(args.colNames, cols, colNamesToInclude);
|
|
179
|
+
const getFilteredCsvRow = getCsvRowFilterFn(uuidIndex, colIndexesToInclude);
|
|
180
|
+
|
|
181
|
+
const rowDocs = rows
|
|
182
|
+
.map(getDocIdForCsvRow(docType, uuidIndex))
|
|
183
|
+
.map(id => documentDocs[id]);
|
|
184
|
+
|
|
185
|
+
rows
|
|
186
|
+
.map(getFilteredCsvRow)
|
|
187
|
+
.map((row, index) => [rowDocs[index], row])
|
|
188
|
+
.map(processCsvRow(colNamesToInclude));
|
|
189
|
+
return rowDocs;
|
|
190
|
+
};
|
|
171
191
|
|
|
172
192
|
const processDocuments = async (docType, csv, ids, db, args) => {
|
|
173
193
|
const documentDocs = await fetchDocumentList(db, ids, args);
|
|
@@ -196,4 +216,4 @@ const parseExtraArgs = (projectDir, extraArgs = []) => {
|
|
|
196
216
|
module.exports = {
|
|
197
217
|
requiresInstance: true,
|
|
198
218
|
execute
|
|
199
|
-
};
|
|
219
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
const minimist = require('minimist');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const environment = require('../lib/environment');
|
|
5
|
+
const pouch = require('../lib/db');
|
|
6
|
+
const { info } = require('../lib/log');
|
|
7
|
+
|
|
8
|
+
const HierarchyOperations = require('../lib/hierarchy-operations');
|
|
9
|
+
|
|
10
|
+
module.exports = {
|
|
11
|
+
requiresInstance: true,
|
|
12
|
+
execute: () => {
|
|
13
|
+
const args = parseExtraArgs(environment.pathToProject, environment.extraArgs);
|
|
14
|
+
const db = pouch();
|
|
15
|
+
const options = {
|
|
16
|
+
docDirectoryPath: args.docDirectoryPath,
|
|
17
|
+
force: args.force,
|
|
18
|
+
};
|
|
19
|
+
return HierarchyOperations(db, options).merge(args.sourceIds, args.destinationId);
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// Parses extraArgs and asserts if required parameters are not present
|
|
24
|
+
const parseExtraArgs = (projectDir, extraArgs = []) => {
|
|
25
|
+
const args = minimist(extraArgs, { boolean: true });
|
|
26
|
+
|
|
27
|
+
const sourceIds = (args.sources || args.source || '')
|
|
28
|
+
.split(',')
|
|
29
|
+
.filter(Boolean);
|
|
30
|
+
|
|
31
|
+
if (!args.destination) {
|
|
32
|
+
usage();
|
|
33
|
+
throw Error(`Action "merge-contacts" is missing required contact ID ${bold('--destination')}. Other contacts will be merged into this contact.`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (sourceIds.length === 0) {
|
|
37
|
+
usage();
|
|
38
|
+
throw Error(`Action "merge-contacts" is missing required contact ID(s) ${bold('--sources')}. These contacts will be merged into the contact specified by ${bold('--destination')}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
destinationId: args.destination,
|
|
43
|
+
sourceIds,
|
|
44
|
+
docDirectoryPath: path.resolve(projectDir, args.docDirectoryPath || 'json_docs'),
|
|
45
|
+
force: !!args.force,
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const bold = text => `\x1b[1m${text}\x1b[0m`;
|
|
50
|
+
const usage = () => {
|
|
51
|
+
info(`
|
|
52
|
+
${bold('cht-conf\'s merge-contacts action')}
|
|
53
|
+
When combined with 'upload-docs' this action moves all of the contacts and reports under ${bold('sources')} to be under ${bold('destination')}.
|
|
54
|
+
The top-level ${bold('source contact(s)')} are deleted and no data from this document is merged or preserved.
|
|
55
|
+
|
|
56
|
+
${bold('USAGE')}
|
|
57
|
+
cht --local merge-contacts -- --destination=<destination_id> --sources=<source_id1>,<source_id2>
|
|
58
|
+
|
|
59
|
+
${bold('OPTIONS')}
|
|
60
|
+
--destination=<destination_id>
|
|
61
|
+
Specifies the ID of the contact that should receive the moving contacts and reports.
|
|
62
|
+
|
|
63
|
+
--sources=<source_id1>,<source_id2>
|
|
64
|
+
A comma delimited list of IDs of contacts which will be deleted. The hierarchy of contacts and reports under it will be moved to be under the destination contact.
|
|
65
|
+
|
|
66
|
+
--docDirectoryPath=<path to stage docs>
|
|
67
|
+
Specifies the folder used to store the documents representing the changes in hierarchy.
|
|
68
|
+
`);
|
|
69
|
+
};
|
package/src/fn/move-contacts.js
CHANGED
|
@@ -1,105 +1,34 @@
|
|
|
1
1
|
const minimist = require('minimist');
|
|
2
2
|
const path = require('path');
|
|
3
|
-
const userPrompt = require('../lib/user-prompt');
|
|
4
3
|
|
|
5
4
|
const environment = require('../lib/environment');
|
|
6
|
-
const fs = require('../lib/sync-fs');
|
|
7
|
-
const lineageManipulation = require('../lib/lineage-manipulation');
|
|
8
|
-
const lineageConstraints = require('../lib/lineage-constraints');
|
|
9
5
|
const pouch = require('../lib/db');
|
|
10
|
-
const {
|
|
6
|
+
const { info } = require('../lib/log');
|
|
11
7
|
|
|
12
|
-
const
|
|
13
|
-
const BATCH_SIZE = 10000;
|
|
8
|
+
const HierarchyOperations = require('../lib/hierarchy-operations');
|
|
14
9
|
|
|
15
10
|
module.exports = {
|
|
16
11
|
requiresInstance: true,
|
|
17
12
|
execute: () => {
|
|
18
13
|
const args = parseExtraArgs(environment.pathToProject, environment.extraArgs);
|
|
19
14
|
const db = pouch();
|
|
20
|
-
|
|
21
|
-
|
|
15
|
+
const options = {
|
|
16
|
+
docDirectoryPath: args.docDirectoryPath,
|
|
17
|
+
force: args.force,
|
|
18
|
+
};
|
|
19
|
+
return HierarchyOperations(db, options).move(args.sourceIds, args.destinationId);
|
|
22
20
|
}
|
|
23
21
|
};
|
|
24
22
|
|
|
25
|
-
const prettyPrintDocument = doc => `'${doc.name}' (${doc._id})`;
|
|
26
|
-
const updateLineagesAndStage = async (options, db) => {
|
|
27
|
-
trace(`Fetching contact details for parent: ${options.parentId}`);
|
|
28
|
-
const parentDoc = await fetch.contact(db, options.parentId);
|
|
29
|
-
|
|
30
|
-
const constraints = await lineageConstraints(db, parentDoc);
|
|
31
|
-
const contactDocs = await fetch.contactList(db, options.contactIds);
|
|
32
|
-
await validateContacts(contactDocs, constraints);
|
|
33
|
-
|
|
34
|
-
let affectedContactCount = 0, affectedReportCount = 0;
|
|
35
|
-
const replacementLineage = lineageManipulation.createLineageFromDoc(parentDoc);
|
|
36
|
-
for (let contactId of options.contactIds) {
|
|
37
|
-
const contactDoc = contactDocs[contactId];
|
|
38
|
-
const descendantsAndSelf = await fetch.descendantsOf(db, contactId);
|
|
39
|
-
|
|
40
|
-
// Check that primary contact is not removed from areas where they are required
|
|
41
|
-
const invalidPrimaryContactDoc = await constraints.getPrimaryContactViolations(contactDoc, descendantsAndSelf);
|
|
42
|
-
if (invalidPrimaryContactDoc) {
|
|
43
|
-
throw Error(`Cannot remove contact ${prettyPrintDocument(invalidPrimaryContactDoc)} from the hierarchy for which they are a primary contact.`);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
trace(`Considering lineage updates to ${descendantsAndSelf.length} descendant(s) of contact ${prettyPrintDocument(contactDoc)}.`);
|
|
47
|
-
const updatedDescendants = replaceLineageInContacts(descendantsAndSelf, replacementLineage, contactId);
|
|
48
|
-
|
|
49
|
-
const ancestors = await fetch.ancestorsOf(db, contactDoc);
|
|
50
|
-
trace(`Considering primary contact updates to ${ancestors.length} ancestor(s) of contact ${prettyPrintDocument(contactDoc)}.`);
|
|
51
|
-
const updatedAncestors = replaceLineageInAncestors(descendantsAndSelf, ancestors);
|
|
52
|
-
|
|
53
|
-
minifyLineageAndWriteToDisk([...updatedDescendants, ...updatedAncestors], options);
|
|
54
|
-
|
|
55
|
-
const movedReportsCount = await moveReports(db, descendantsAndSelf, options, replacementLineage, contactId);
|
|
56
|
-
trace(`${movedReportsCount} report(s) created by these affected contact(s) will be updated`);
|
|
57
|
-
|
|
58
|
-
affectedContactCount += updatedDescendants.length + updatedAncestors.length;
|
|
59
|
-
affectedReportCount += movedReportsCount;
|
|
60
|
-
|
|
61
|
-
info(`Staged updates to ${prettyPrintDocument(contactDoc)}. ${updatedDescendants.length} contact(s) and ${movedReportsCount} report(s).`);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
info(`Staged changes to lineage information for ${affectedContactCount} contact(s) and ${affectedReportCount} report(s).`);
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
/*
|
|
68
|
-
Checks for any errors which this will create in the hierarchy (hierarchy schema, circular hierarchies)
|
|
69
|
-
Confirms the list of contacts are possible to move
|
|
70
|
-
*/
|
|
71
|
-
const validateContacts = async (contactDocs, constraints) => {
|
|
72
|
-
Object.values(contactDocs).forEach(doc => {
|
|
73
|
-
const hierarchyError = constraints.getHierarchyErrors(doc);
|
|
74
|
-
if (hierarchyError) {
|
|
75
|
-
throw Error(`Hierarchy Constraints: ${hierarchyError}`);
|
|
76
|
-
}
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
/*
|
|
80
|
-
It is nice that the tool can move lists of contacts as one operation, but strange things happen when two contactIds are in the same lineage.
|
|
81
|
-
For example, moving a district_hospital and moving a contact under that district_hospital to a new clinic causes multiple colliding writes to the same json file.
|
|
82
|
-
*/
|
|
83
|
-
const contactIds = Object.keys(contactDocs);
|
|
84
|
-
Object.values(contactDocs)
|
|
85
|
-
.forEach(doc => {
|
|
86
|
-
const parentIdsOfDoc = (doc.parent && lineageManipulation.pluckIdsFromLineage(doc.parent)) || [];
|
|
87
|
-
const violatingParentId = parentIdsOfDoc.find(parentId => contactIds.includes(parentId));
|
|
88
|
-
if (violatingParentId) {
|
|
89
|
-
throw Error(`Unable to move two documents from the same lineage: '${doc._id}' and '${violatingParentId}'`);
|
|
90
|
-
}
|
|
91
|
-
});
|
|
92
|
-
};
|
|
93
|
-
|
|
94
23
|
// Parses extraArgs and asserts if required parameters are not present
|
|
95
24
|
const parseExtraArgs = (projectDir, extraArgs = []) => {
|
|
96
25
|
const args = minimist(extraArgs, { boolean: true });
|
|
97
26
|
|
|
98
|
-
const
|
|
27
|
+
const sourceIds = (args.contacts || args.contact || '')
|
|
99
28
|
.split(',')
|
|
100
29
|
.filter(id => id);
|
|
101
30
|
|
|
102
|
-
if (
|
|
31
|
+
if (sourceIds.length === 0) {
|
|
103
32
|
usage();
|
|
104
33
|
throw Error('Action "move-contacts" is missing required list of contacts to be moved');
|
|
105
34
|
}
|
|
@@ -110,28 +39,15 @@ const parseExtraArgs = (projectDir, extraArgs = []) => {
|
|
|
110
39
|
}
|
|
111
40
|
|
|
112
41
|
return {
|
|
113
|
-
|
|
114
|
-
|
|
42
|
+
destinationId: args.parent,
|
|
43
|
+
sourceIds,
|
|
115
44
|
docDirectoryPath: path.resolve(projectDir, args.docDirectoryPath || 'json_docs'),
|
|
116
45
|
force: !!args.force,
|
|
117
46
|
};
|
|
118
47
|
};
|
|
119
48
|
|
|
120
|
-
const
|
|
121
|
-
if (!fs.exists(docDirectoryPath)) {
|
|
122
|
-
fs.mkdir(docDirectoryPath);
|
|
123
|
-
} else if (!force && fs.recurseFiles(docDirectoryPath).length > 0) {
|
|
124
|
-
warn(`The document folder '${docDirectoryPath}' already contains files. It is recommended you start with a clean folder. Do you want to delete the contents of this folder and continue?`);
|
|
125
|
-
if(userPrompt.keyInYN()) {
|
|
126
|
-
fs.deleteFilesInFolder(docDirectoryPath);
|
|
127
|
-
} else {
|
|
128
|
-
throw new Error('User aborted execution.');
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
};
|
|
132
|
-
|
|
49
|
+
const bold = text => `\x1b[1m${text}\x1b[0m`;
|
|
133
50
|
const usage = () => {
|
|
134
|
-
const bold = text => `\x1b[1m${text}\x1b[0m`;
|
|
135
51
|
info(`
|
|
136
52
|
${bold('cht-conf\'s move-contacts action')}
|
|
137
53
|
When combined with 'upload-docs' this action effectively moves a contact from one place in the hierarchy to another.
|
|
@@ -144,148 +60,9 @@ ${bold('OPTIONS')}
|
|
|
144
60
|
A comma delimited list of ids of contacts to be moved.
|
|
145
61
|
|
|
146
62
|
--parent=<parent_id>
|
|
147
|
-
Specifies the ID of the new parent. Use '${HIERARCHY_ROOT}' to identify the top of the hierarchy (no parent).
|
|
63
|
+
Specifies the ID of the new parent. Use '${HierarchyOperations.HIERARCHY_ROOT}' to identify the top of the hierarchy (no parent).
|
|
148
64
|
|
|
149
65
|
--docDirectoryPath=<path to stage docs>
|
|
150
66
|
Specifies the folder used to store the documents representing the changes in hierarchy.
|
|
151
67
|
`);
|
|
152
68
|
};
|
|
153
|
-
|
|
154
|
-
const moveReports = async (db, descendantsAndSelf, writeOptions, replacementLineage, contactId) => {
|
|
155
|
-
const contactIds = descendantsAndSelf.map(contact => contact._id);
|
|
156
|
-
|
|
157
|
-
let skip = 0;
|
|
158
|
-
let reportDocsBatch;
|
|
159
|
-
do {
|
|
160
|
-
info(`Processing ${skip} to ${skip + BATCH_SIZE} report docs`);
|
|
161
|
-
reportDocsBatch = await fetch.reportsCreatedBy(db, contactIds, skip);
|
|
162
|
-
|
|
163
|
-
const updatedReports = replaceLineageInReports(reportDocsBatch, replacementLineage, contactId);
|
|
164
|
-
minifyLineageAndWriteToDisk(updatedReports, writeOptions);
|
|
165
|
-
|
|
166
|
-
skip += reportDocsBatch.length;
|
|
167
|
-
} while (reportDocsBatch.length >= BATCH_SIZE);
|
|
168
|
-
|
|
169
|
-
return skip;
|
|
170
|
-
};
|
|
171
|
-
|
|
172
|
-
const minifyLineageAndWriteToDisk = (docs, parsedArgs) => {
|
|
173
|
-
docs.forEach(doc => {
|
|
174
|
-
lineageManipulation.minifyLineagesInDoc(doc);
|
|
175
|
-
writeDocumentToDisk(parsedArgs, doc);
|
|
176
|
-
});
|
|
177
|
-
};
|
|
178
|
-
|
|
179
|
-
const writeDocumentToDisk = ({ docDirectoryPath }, doc) => {
|
|
180
|
-
const destinationPath = path.join(docDirectoryPath, `${doc._id}.doc.json`);
|
|
181
|
-
if (fs.exists(destinationPath)) {
|
|
182
|
-
warn(`File at ${destinationPath} already exists and is being overwritten.`);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
trace(`Writing updated document to ${destinationPath}`);
|
|
186
|
-
fs.writeJson(destinationPath, doc);
|
|
187
|
-
};
|
|
188
|
-
|
|
189
|
-
const fetch = {
|
|
190
|
-
/*
|
|
191
|
-
Fetches all of the documents associated with the "contactIds" and confirms they exist.
|
|
192
|
-
*/
|
|
193
|
-
contactList: async (db, ids) => {
|
|
194
|
-
const contactDocs = await db.allDocs({
|
|
195
|
-
keys: ids,
|
|
196
|
-
include_docs: true,
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
const missingContactErrors = contactDocs.rows.filter(row => !row.doc).map(row => `Contact with id '${row.key}' could not be found.`);
|
|
200
|
-
if (missingContactErrors.length > 0) {
|
|
201
|
-
throw Error(missingContactErrors);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
return contactDocs.rows.reduce((agg, curr) => Object.assign(agg, { [curr.doc._id]: curr.doc }), {});
|
|
205
|
-
},
|
|
206
|
-
|
|
207
|
-
contact: async (db, id) => {
|
|
208
|
-
try {
|
|
209
|
-
if (id === HIERARCHY_ROOT) {
|
|
210
|
-
return undefined;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
return await db.get(id);
|
|
214
|
-
} catch (err) {
|
|
215
|
-
if (err.name !== 'not_found') {
|
|
216
|
-
throw err;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
throw Error(`Contact with id '${id}' could not be found`);
|
|
220
|
-
}
|
|
221
|
-
},
|
|
222
|
-
|
|
223
|
-
/*
|
|
224
|
-
Given a contact's id, obtain the documents of all descendant contacts
|
|
225
|
-
*/
|
|
226
|
-
descendantsOf: async (db, contactId) => {
|
|
227
|
-
const descendantDocs = await db.query('medic/contacts_by_depth', {
|
|
228
|
-
key: [contactId],
|
|
229
|
-
include_docs: true,
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
return descendantDocs.rows
|
|
233
|
-
.map(row => row.doc)
|
|
234
|
-
/* We should not move or update tombstone documents */
|
|
235
|
-
.filter(doc => doc && doc.type !== 'tombstone');
|
|
236
|
-
},
|
|
237
|
-
|
|
238
|
-
reportsCreatedBy: async (db, contactIds, skip) => {
|
|
239
|
-
const reports = await db.query('medic-client/reports_by_freetext', {
|
|
240
|
-
keys: contactIds.map(id => [`contact:${id}`]),
|
|
241
|
-
include_docs: true,
|
|
242
|
-
limit: BATCH_SIZE,
|
|
243
|
-
skip: skip,
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
return reports.rows.map(row => row.doc);
|
|
247
|
-
},
|
|
248
|
-
|
|
249
|
-
ancestorsOf: async (db, contactDoc) => {
|
|
250
|
-
const ancestorIds = lineageManipulation.pluckIdsFromLineage(contactDoc.parent);
|
|
251
|
-
const ancestors = await db.allDocs({
|
|
252
|
-
keys: ancestorIds,
|
|
253
|
-
include_docs: true,
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
const ancestorIdsNotFound = ancestors.rows.filter(ancestor => !ancestor.doc).map(ancestor => ancestor.key);
|
|
257
|
-
if (ancestorIdsNotFound.length > 0) {
|
|
258
|
-
throw Error(`Contact '${prettyPrintDocument(contactDoc)} has parent id(s) '${ancestorIdsNotFound.join(',')}' which could not be found.`);
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
return ancestors.rows.map(ancestor => ancestor.doc);
|
|
262
|
-
},
|
|
263
|
-
};
|
|
264
|
-
|
|
265
|
-
const replaceLineageInReports = (reportsCreatedByDescendants, replaceWith, startingFromIdInLineage) => reportsCreatedByDescendants.reduce((agg, doc) => {
|
|
266
|
-
if (lineageManipulation.replaceLineage(doc, 'contact', replaceWith, startingFromIdInLineage)) {
|
|
267
|
-
agg.push(doc);
|
|
268
|
-
}
|
|
269
|
-
return agg;
|
|
270
|
-
}, []);
|
|
271
|
-
|
|
272
|
-
const replaceLineageInContacts = (descendantsAndSelf, replacementLineage, contactId) => descendantsAndSelf.reduce((agg, doc) => {
|
|
273
|
-
const startingFromIdInLineage = doc._id === contactId ? undefined : contactId;
|
|
274
|
-
const parentWasUpdated = lineageManipulation.replaceLineage(doc, 'parent', replacementLineage, startingFromIdInLineage);
|
|
275
|
-
const contactWasUpdated = lineageManipulation.replaceLineage(doc, 'contact', replacementLineage, contactId);
|
|
276
|
-
if (parentWasUpdated || contactWasUpdated) {
|
|
277
|
-
agg.push(doc);
|
|
278
|
-
}
|
|
279
|
-
return agg;
|
|
280
|
-
}, []);
|
|
281
|
-
|
|
282
|
-
const replaceLineageInAncestors = (descendantsAndSelf, ancestors) => ancestors.reduce((agg, ancestor) => {
|
|
283
|
-
let result = agg;
|
|
284
|
-
const primaryContact = descendantsAndSelf.find(descendant => ancestor.contact && descendant._id === ancestor.contact._id);
|
|
285
|
-
if (primaryContact) {
|
|
286
|
-
ancestor.contact = lineageManipulation.createLineageFromDoc(primaryContact);
|
|
287
|
-
result = [ancestor, ...result];
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
return result;
|
|
291
|
-
}, []);
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
const lineageManipulation = require('./lineage-manipulation');
|
|
2
|
+
|
|
3
|
+
const HIERARCHY_ROOT = 'root';
|
|
4
|
+
const BATCH_SIZE = 10000;
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
Fetches all of the documents associated with the "contactIds" and confirms they exist.
|
|
8
|
+
*/
|
|
9
|
+
async function getContactsByIds(db, ids) {
|
|
10
|
+
const contactDocs = await db.allDocs({
|
|
11
|
+
keys: ids,
|
|
12
|
+
include_docs: true,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const missingContactErrors = contactDocs.rows.filter(row => !row.doc).map(row => `Contact with id '${row.key}' could not be found.`);
|
|
16
|
+
if (missingContactErrors.length > 0) {
|
|
17
|
+
throw Error(missingContactErrors);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const contactDict = {};
|
|
21
|
+
contactDocs.rows.forEach(({ doc }) => contactDict[doc._id] = doc);
|
|
22
|
+
return contactDict;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function getContact(db, id) {
|
|
26
|
+
try {
|
|
27
|
+
if (id === HIERARCHY_ROOT) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return await db.get(id);
|
|
32
|
+
} catch (err) {
|
|
33
|
+
if (err.status !== 404) {
|
|
34
|
+
throw err;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
throw Error(`Contact with id '${id}' could not be found`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/*
|
|
42
|
+
Given a contact's id, obtain the documents of all descendant contacts
|
|
43
|
+
*/
|
|
44
|
+
async function getContactWithDescendants(db, contactId) {
|
|
45
|
+
const descendantDocs = await db.query('medic/contacts_by_depth', {
|
|
46
|
+
key: [contactId],
|
|
47
|
+
include_docs: true,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
return descendantDocs.rows
|
|
51
|
+
.map(row => row.doc)
|
|
52
|
+
// We should not move or update tombstone documents
|
|
53
|
+
// Not relevant for 4.x cht-core versions, but needed in older versions.
|
|
54
|
+
.filter(doc => doc && doc.type !== 'tombstone');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function getReportsForContacts(db, createdByIds, createdAtId, skip) {
|
|
58
|
+
const createdByKeys = createdByIds.map(id => [`contact:${id}`]);
|
|
59
|
+
const createdAtKeys = createdAtId ? [
|
|
60
|
+
[`patient_id:${createdAtId}`],
|
|
61
|
+
[`patient_uuid:${createdAtId}`],
|
|
62
|
+
[`place_id:${createdAtId}`],
|
|
63
|
+
[`place_uuid:${createdAtId}`]
|
|
64
|
+
] : [];
|
|
65
|
+
|
|
66
|
+
const reports = await db.query('medic-client/reports_by_freetext', {
|
|
67
|
+
keys: [
|
|
68
|
+
...createdByKeys,
|
|
69
|
+
...createdAtKeys,
|
|
70
|
+
],
|
|
71
|
+
include_docs: true,
|
|
72
|
+
limit: BATCH_SIZE,
|
|
73
|
+
skip,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const docsWithId = reports.rows.map(({ doc }) => [doc._id, doc]);
|
|
77
|
+
return Array.from(new Map(docsWithId).values());
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function getAncestorsOf(db, contactDoc) {
|
|
81
|
+
const ancestorIds = lineageManipulation.pluckIdsFromLineage(contactDoc.parent);
|
|
82
|
+
const ancestors = await db.allDocs({
|
|
83
|
+
keys: ancestorIds,
|
|
84
|
+
include_docs: true,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const ancestorIdsNotFound = ancestors.rows.filter(ancestor => !ancestor.doc).map(ancestor => ancestor.key);
|
|
88
|
+
if (ancestorIdsNotFound.length > 0) {
|
|
89
|
+
throw Error(`Contact '${contactDoc?.name}' (${contactDoc?._id}) has parent id(s) '${ancestorIdsNotFound.join(',')}' which could not be found.`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return ancestors.rows.map(ancestor => ancestor.doc);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = {
|
|
96
|
+
BATCH_SIZE,
|
|
97
|
+
HIERARCHY_ROOT,
|
|
98
|
+
getAncestorsOf,
|
|
99
|
+
getContactWithDescendants,
|
|
100
|
+
getContact,
|
|
101
|
+
getContactsByIds,
|
|
102
|
+
getReportsForContacts,
|
|
103
|
+
};
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
const lineageManipulation = require('./lineage-manipulation');
|
|
2
|
+
const LineageConstraints = require('./lineage-constraints');
|
|
3
|
+
const { trace, info } = require('../log');
|
|
4
|
+
|
|
5
|
+
const JsDocs = require('./jsdocFolder');
|
|
6
|
+
const DataSource = require('./hierarchy-data-source');
|
|
7
|
+
|
|
8
|
+
async function moveHierarchy(db, options, sourceIds, destinationId) {
|
|
9
|
+
JsDocs.prepareFolder(options);
|
|
10
|
+
trace(`Fetching contact details: ${destinationId}`);
|
|
11
|
+
const constraints = await LineageConstraints(db, options);
|
|
12
|
+
const destinationDoc = await DataSource.getContact(db, destinationId);
|
|
13
|
+
const sourceDocs = await DataSource.getContactsByIds(db, sourceIds);
|
|
14
|
+
constraints.assertNoHierarchyErrors(Object.values(sourceDocs), destinationDoc);
|
|
15
|
+
|
|
16
|
+
let affectedContactCount = 0;
|
|
17
|
+
let affectedReportCount = 0;
|
|
18
|
+
const replacementLineage = lineageManipulation.createLineageFromDoc(destinationDoc);
|
|
19
|
+
for (const sourceId of sourceIds) {
|
|
20
|
+
const sourceDoc = sourceDocs[sourceId];
|
|
21
|
+
const descendantsAndSelf = await DataSource.getContactWithDescendants(db, sourceId);
|
|
22
|
+
const moveContext = {
|
|
23
|
+
sourceId,
|
|
24
|
+
destinationId,
|
|
25
|
+
descendantsAndSelf,
|
|
26
|
+
replacementLineage,
|
|
27
|
+
merge: !!options.merge,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
await constraints.assertNoPrimaryContactViolations(sourceDoc, destinationDoc, descendantsAndSelf);
|
|
31
|
+
|
|
32
|
+
if (options.merge) {
|
|
33
|
+
JsDocs.writeDoc(options, {
|
|
34
|
+
_id: sourceDoc._id,
|
|
35
|
+
_rev: sourceDoc._rev,
|
|
36
|
+
_deleted: true,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const prettyPrintDocument = doc => `'${doc.name}' (${doc._id})`;
|
|
41
|
+
trace(`Considering lineage updates to ${descendantsAndSelf.length} descendant(s) of contact ${prettyPrintDocument(sourceDoc)}.`);
|
|
42
|
+
const updatedDescendants = replaceLineageInContacts(moveContext);
|
|
43
|
+
|
|
44
|
+
const ancestors = await DataSource.getAncestorsOf(db, sourceDoc);
|
|
45
|
+
trace(`Considering primary contact updates to ${ancestors.length} ancestor(s) of contact ${prettyPrintDocument(sourceDoc)}.`);
|
|
46
|
+
const updatedAncestors = replaceLineageInAncestors(descendantsAndSelf, ancestors);
|
|
47
|
+
|
|
48
|
+
minifyLineageAndWriteToDisk(options, [...updatedDescendants, ...updatedAncestors]);
|
|
49
|
+
|
|
50
|
+
const movedReportsCount = await updateReports(db, options, moveContext);
|
|
51
|
+
trace(`${movedReportsCount} report(s) created by these affected contact(s) will be updated`);
|
|
52
|
+
|
|
53
|
+
affectedContactCount += updatedDescendants.length + updatedAncestors.length;
|
|
54
|
+
affectedReportCount += movedReportsCount;
|
|
55
|
+
|
|
56
|
+
info(`Staged updates to ${prettyPrintDocument(sourceDoc)}. ${updatedDescendants.length} contact(s) and ${movedReportsCount} report(s).`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
info(`Staged changes to lineage information for ${affectedContactCount} contact(s) and ${affectedReportCount} report(s).`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function updateReports(db, options, moveContext) {
|
|
63
|
+
const descendantIds = moveContext.descendantsAndSelf.map(contact => contact._id);
|
|
64
|
+
|
|
65
|
+
let skip = 0;
|
|
66
|
+
let reportDocsBatch;
|
|
67
|
+
do {
|
|
68
|
+
info(`Processing ${skip} to ${skip + DataSource.BATCH_SIZE} report docs`);
|
|
69
|
+
const createdAtId = options.merge && moveContext.sourceId;
|
|
70
|
+
reportDocsBatch = await DataSource.getReportsForContacts(db, descendantIds, createdAtId, skip);
|
|
71
|
+
|
|
72
|
+
const lineageUpdates = replaceLineageOfReportCreator(reportDocsBatch, moveContext);
|
|
73
|
+
const reassignUpdates = reassignReports(reportDocsBatch, moveContext);
|
|
74
|
+
const updatedReports = reportDocsBatch.filter(doc => lineageUpdates.has(doc._id) || reassignUpdates.has(doc._id));
|
|
75
|
+
|
|
76
|
+
minifyLineageAndWriteToDisk(options, updatedReports);
|
|
77
|
+
|
|
78
|
+
skip += reportDocsBatch.length;
|
|
79
|
+
} while (reportDocsBatch.length >= DataSource.BATCH_SIZE);
|
|
80
|
+
|
|
81
|
+
return skip;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function reassignReportSubjects(report, { sourceId, destinationId }) {
|
|
85
|
+
const SUBJECT_IDS = ['patient_id', 'patient_uuid', 'place_id', 'place_uuid'];
|
|
86
|
+
let updated = false;
|
|
87
|
+
for (const subjectId of SUBJECT_IDS) {
|
|
88
|
+
if (report[subjectId] === sourceId) {
|
|
89
|
+
report[subjectId] = destinationId;
|
|
90
|
+
updated = true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (report.fields[subjectId] === sourceId) {
|
|
94
|
+
report.fields[subjectId] = destinationId;
|
|
95
|
+
updated = true;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return updated;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function reassignReports(reports, moveContext) {
|
|
103
|
+
const updated = new Set();
|
|
104
|
+
if (!moveContext.merge) {
|
|
105
|
+
return updated;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const report of reports) {
|
|
109
|
+
const isUpdated = reassignReportSubjects(report, moveContext);
|
|
110
|
+
if (isUpdated) {
|
|
111
|
+
updated.add(report._id);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return updated;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// This ensures all documents written are fully minified. Some docs in CouchDB are not minified to start with.
|
|
119
|
+
function minifyLineageAndWriteToDisk(options, docs) {
|
|
120
|
+
docs.forEach(doc => {
|
|
121
|
+
lineageManipulation.minifyLineagesInDoc(doc);
|
|
122
|
+
JsDocs.writeDoc(options, doc);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function replaceLineageOfReportCreator(reports, moveContext) {
|
|
127
|
+
const replaceContactLineage = doc => lineageManipulation.replaceContactLineage(doc, {
|
|
128
|
+
replaceWith: moveContext.replacementLineage,
|
|
129
|
+
startingFromId: moveContext.sourceId,
|
|
130
|
+
merge: moveContext.merge,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const updatedReportIds = reports
|
|
134
|
+
.filter(replaceContactLineage)
|
|
135
|
+
.map(({ _id }) => _id);
|
|
136
|
+
return new Set(updatedReportIds);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function replaceLineageInAncestors(descendantsAndSelf, ancestors) {
|
|
140
|
+
const updatedAncestors = [];
|
|
141
|
+
for (const ancestor of ancestors) {
|
|
142
|
+
const primaryContact = descendantsAndSelf.find(descendant => descendant._id === ancestor.contact?._id);
|
|
143
|
+
if (primaryContact) {
|
|
144
|
+
ancestor.contact = lineageManipulation.createLineageFromDoc(primaryContact);
|
|
145
|
+
updatedAncestors.unshift(ancestor);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return updatedAncestors;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function replaceLineageInSingleContact(doc, moveContext) {
|
|
153
|
+
const docIsSource = doc._id === moveContext.sourceId;
|
|
154
|
+
if (docIsSource && moveContext.merge) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const startingFromId = moveContext.merge || !docIsSource ? moveContext.sourceId : undefined;
|
|
159
|
+
const replaceLineageOptions = {
|
|
160
|
+
replaceWith: moveContext.replacementLineage,
|
|
161
|
+
startingFromId,
|
|
162
|
+
merge: moveContext.merge,
|
|
163
|
+
};
|
|
164
|
+
const parentWasUpdated = lineageManipulation.replaceParentLineage(doc, replaceLineageOptions);
|
|
165
|
+
|
|
166
|
+
replaceLineageOptions.startingFromId = moveContext.sourceId;
|
|
167
|
+
const contactWasUpdated = lineageManipulation.replaceContactLineage(doc, replaceLineageOptions);
|
|
168
|
+
if (parentWasUpdated || contactWasUpdated) {
|
|
169
|
+
return doc;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function replaceLineageInContacts(moveContext) {
|
|
174
|
+
return moveContext.descendantsAndSelf
|
|
175
|
+
.map(descendant => replaceLineageInSingleContact(descendant, moveContext))
|
|
176
|
+
.filter(Boolean);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
module.exports = (db, options) => {
|
|
180
|
+
return {
|
|
181
|
+
HIERARCHY_ROOT: DataSource.HIERARCHY_ROOT,
|
|
182
|
+
move: (sourceIds, destinationId) => moveHierarchy(db, { ...options, merge: false }, sourceIds, destinationId),
|
|
183
|
+
merge: (sourceIds, destinationId) => moveHierarchy(db, { ...options, merge: true }, sourceIds, destinationId),
|
|
184
|
+
};
|
|
185
|
+
};
|
|
186
|
+
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const userPrompt = require('../user-prompt');
|
|
3
|
+
const fs = require('../sync-fs');
|
|
4
|
+
const { warn, trace } = require('../log');
|
|
5
|
+
|
|
6
|
+
function prepareFolder({ docDirectoryPath, force }) {
|
|
7
|
+
if (!fs.exists(docDirectoryPath)) {
|
|
8
|
+
fs.mkdir(docDirectoryPath);
|
|
9
|
+
} else if (!force && fs.recurseFiles(docDirectoryPath).length > 0) {
|
|
10
|
+
deleteAfterConfirmation(docDirectoryPath);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function writeDoc({ docDirectoryPath }, doc) {
|
|
15
|
+
const destinationPath = path.join(docDirectoryPath, `${doc._id}.doc.json`);
|
|
16
|
+
if (fs.exists(destinationPath)) {
|
|
17
|
+
warn(`File at ${destinationPath} already exists and is being overwritten.`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
trace(`Writing updated document to ${destinationPath}`);
|
|
21
|
+
fs.writeJson(destinationPath, doc);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function deleteAfterConfirmation(docDirectoryPath) {
|
|
25
|
+
warn(`The document folder '${docDirectoryPath}' already contains files. It is recommended you start with a clean folder. Do you want to delete the contents of this folder and continue?`);
|
|
26
|
+
if (!userPrompt.keyInYN()) {
|
|
27
|
+
throw new Error('User aborted execution.');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
fs.deleteFilesInFolder(docDirectoryPath);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
module.exports = {
|
|
34
|
+
prepareFolder,
|
|
35
|
+
writeDoc,
|
|
36
|
+
};
|
|
37
|
+
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
const log = require('../log');
|
|
2
|
+
const { HIERARCHY_ROOT } = require('./hierarchy-data-source');
|
|
3
|
+
const { trace } = log;
|
|
4
|
+
|
|
5
|
+
const lineageManipulation = require('./lineage-manipulation');
|
|
6
|
+
|
|
7
|
+
module.exports = async (db, options) => {
|
|
8
|
+
const mapTypeToAllowedParents = await fetchAllowedParents(db);
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
assertNoPrimaryContactViolations: async (sourceDoc, destinationDoc, descendantDocs) => {
|
|
12
|
+
const invalidPrimaryContactDoc = await getPrimaryContactViolations(db, sourceDoc, destinationDoc, descendantDocs);
|
|
13
|
+
if (invalidPrimaryContactDoc) {
|
|
14
|
+
throw Error(`Cannot remove contact '${invalidPrimaryContactDoc?.name}' (${invalidPrimaryContactDoc?._id}) from the hierarchy for which they are a primary contact.`);
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
assertNoHierarchyErrors: (sourceDocs, destinationDoc) => {
|
|
18
|
+
|
|
19
|
+
sourceDocs.forEach(sourceDoc => {
|
|
20
|
+
const commonViolations = getCommonViolations(sourceDoc, destinationDoc);
|
|
21
|
+
const specificViolation = options.merge ?
|
|
22
|
+
getMergeViolations(sourceDoc, destinationDoc)
|
|
23
|
+
: getMovingViolations(mapTypeToAllowedParents, sourceDoc, destinationDoc);
|
|
24
|
+
|
|
25
|
+
const hierarchyError = commonViolations || specificViolation;
|
|
26
|
+
if (hierarchyError) {
|
|
27
|
+
throw Error(`Hierarchy Constraints: ${hierarchyError}`);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/*
|
|
32
|
+
It is nice that the tool can move lists of contacts as one operation, but strange things happen when two contactIds are in the same lineage.
|
|
33
|
+
For example, moving a district_hospital and moving a contact under that district_hospital to a new clinic causes multiple colliding writes to the same json file.
|
|
34
|
+
*/
|
|
35
|
+
const contactIds = sourceDocs.map(doc => doc._id);
|
|
36
|
+
sourceDocs
|
|
37
|
+
.forEach(doc => {
|
|
38
|
+
const parentIdsOfDoc = lineageManipulation.pluckIdsFromLineage(doc.parent);
|
|
39
|
+
const violatingParentId = parentIdsOfDoc.find(parentId => contactIds.includes(parentId));
|
|
40
|
+
if (violatingParentId) {
|
|
41
|
+
throw Error(`Unable to move two documents from the same lineage: '${doc._id}' and '${violatingParentId}'`);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/*
|
|
49
|
+
Enforce the list of allowed parents for each contact type
|
|
50
|
+
Ensure we are not creating a circular hierarchy
|
|
51
|
+
*/
|
|
52
|
+
const getMovingViolations = (mapTypeToAllowedParents, sourceDoc, destinationDoc) => {
|
|
53
|
+
const contactTypeError = getMovingContactTypeError(mapTypeToAllowedParents, sourceDoc, destinationDoc);
|
|
54
|
+
const circularHierarchyError = findCircularHierarchyErrors(sourceDoc, destinationDoc);
|
|
55
|
+
return contactTypeError || circularHierarchyError;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function getMovingContactTypeError(mapTypeToAllowedParents, sourceDoc, destinationDoc) {
|
|
59
|
+
const sourceContactType = getContactType(sourceDoc);
|
|
60
|
+
const destinationType = getContactType(destinationDoc);
|
|
61
|
+
const rulesForContact = mapTypeToAllowedParents[sourceContactType];
|
|
62
|
+
if (!rulesForContact) {
|
|
63
|
+
return `cannot move contact with unknown type '${sourceContactType}'`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const isPermittedMoveToRoot = !destinationDoc && rulesForContact.length === 0;
|
|
67
|
+
if (!isPermittedMoveToRoot && !rulesForContact.includes(destinationType)) {
|
|
68
|
+
return `contacts of type '${sourceContactType}' cannot have parent of type '${destinationType}'`;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function findCircularHierarchyErrors(sourceDoc, destinationDoc) {
|
|
73
|
+
if (!destinationDoc) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const parentAncestry = lineageManipulation.pluckIdsFromLineage(destinationDoc);
|
|
78
|
+
if (parentAncestry.includes(sourceDoc._id)) {
|
|
79
|
+
return `Circular hierarchy: Cannot set parent of contact '${sourceDoc._id}' as it would create a circular hierarchy.`;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const getCommonViolations = (sourceDoc, destinationDoc) => {
|
|
84
|
+
if (!sourceDoc) {
|
|
85
|
+
return `source doc cannot be found`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const sourceContactType = getContactType(sourceDoc);
|
|
89
|
+
const destinationContactType = getContactType(destinationDoc);
|
|
90
|
+
if (!sourceContactType) {
|
|
91
|
+
return `source contact "${sourceDoc._id}" required attribute "type" is undefined`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (destinationDoc && !destinationContactType) {
|
|
95
|
+
return `destination contact "${destinationDoc._id}" required attribute "type" is undefined`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (sourceDoc._id === destinationDoc?._id) {
|
|
99
|
+
return `cannot move or merge contact that is itself`;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const getMergeViolations = (sourceDoc, destinationDoc) => {
|
|
104
|
+
if (!destinationDoc) {
|
|
105
|
+
return `destination doc cannot be found`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if ([sourceDoc._id, destinationDoc._id].includes(HIERARCHY_ROOT)) {
|
|
109
|
+
return `cannot merge using id: "${HIERARCHY_ROOT}"`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const sourceContactType = getContactType(sourceDoc);
|
|
113
|
+
const destinationContactType = getContactType(destinationDoc);
|
|
114
|
+
if (sourceContactType !== destinationContactType) {
|
|
115
|
+
return `source and destinations must have the same type. Source is "${sourceContactType}" while destination is "${destinationContactType}".`;
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/*
|
|
120
|
+
A place's primary contact must be a descendant of that place.
|
|
121
|
+
|
|
122
|
+
1. Check to see which part of the contact's lineage will be removed
|
|
123
|
+
2. For each removed part of the contact's lineage, confirm that place's primary contact isn't being removed.
|
|
124
|
+
*/
|
|
125
|
+
const getPrimaryContactViolations = async (db, contactDoc, destinationDoc, descendantDocs) => {
|
|
126
|
+
const contactsLineageIds = lineageManipulation.pluckIdsFromLineage(contactDoc?.parent);
|
|
127
|
+
const parentsLineageIds = lineageManipulation.pluckIdsFromLineage(destinationDoc);
|
|
128
|
+
|
|
129
|
+
const docIdsRemovedFromContactLineage = contactsLineageIds.filter(value => !parentsLineageIds.includes(value));
|
|
130
|
+
const docsRemovedFromContactLineage = await db.allDocs({
|
|
131
|
+
keys: docIdsRemovedFromContactLineage,
|
|
132
|
+
include_docs: true,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const primaryContactIds = docsRemovedFromContactLineage.rows
|
|
136
|
+
.map(row => row?.doc?.contact?._id)
|
|
137
|
+
.filter(Boolean);
|
|
138
|
+
|
|
139
|
+
return descendantDocs.find(descendant => primaryContactIds.some(primaryId => descendant._id === primaryId));
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const getContactType = doc => doc && (doc.type === 'contact' ? doc.contact_type : doc.type);
|
|
143
|
+
|
|
144
|
+
async function fetchAllowedParents(db) {
|
|
145
|
+
try {
|
|
146
|
+
const { settings: { contact_types } } = await db.get('settings');
|
|
147
|
+
|
|
148
|
+
if (Array.isArray(contact_types)) {
|
|
149
|
+
trace('Found app_settings.contact_types. Configurable hierarchy constraints will be enforced.');
|
|
150
|
+
const parentDict = {};
|
|
151
|
+
contact_types
|
|
152
|
+
.filter(Boolean)
|
|
153
|
+
.forEach(({ id, parents }) => parentDict[id] = parents);
|
|
154
|
+
return parentDict;
|
|
155
|
+
}
|
|
156
|
+
} catch (err) {
|
|
157
|
+
if (err.status !== 404) {
|
|
158
|
+
throw err;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
trace('Default hierarchy constraints will be enforced.');
|
|
163
|
+
return {
|
|
164
|
+
district_hospital: [],
|
|
165
|
+
health_center: ['district_hospital'],
|
|
166
|
+
clinic: ['health_center'],
|
|
167
|
+
person: ['district_hospital', 'health_center', 'clinic'],
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const { replaceContactLineage, replaceParentLineage } = require('./replace-lineage');
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Function borrowed from shared-lib/lineage
|
|
5
|
+
*/
|
|
6
|
+
const minifyLineagesInDoc = doc => {
|
|
7
|
+
const minifyLineage = lineage => {
|
|
8
|
+
if (!lineage?._id) {
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return {
|
|
13
|
+
_id: lineage._id,
|
|
14
|
+
parent: minifyLineage(lineage.parent),
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
if (!doc) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if ('parent' in doc) {
|
|
23
|
+
doc.parent = minifyLineage(doc.parent);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if ('contact' in doc) {
|
|
27
|
+
doc.contact = minifyLineage(doc.contact);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (doc.type === 'data_record') {
|
|
31
|
+
delete doc.patient;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const createLineageFromDoc = doc => {
|
|
36
|
+
if (!doc) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
_id: doc._id,
|
|
42
|
+
parent: doc.parent || undefined,
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/*
|
|
47
|
+
Given a lineage, return the ids therein
|
|
48
|
+
*/
|
|
49
|
+
const pluckIdsFromLineage = (lineage, results = []) => {
|
|
50
|
+
if (!lineage) {
|
|
51
|
+
return results;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return pluckIdsFromLineage(lineage.parent, [...results, lineage._id]);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
module.exports = {
|
|
58
|
+
createLineageFromDoc,
|
|
59
|
+
minifyLineagesInDoc,
|
|
60
|
+
pluckIdsFromLineage,
|
|
61
|
+
replaceParentLineage,
|
|
62
|
+
replaceContactLineage,
|
|
63
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
function replaceLineage(doc, lineageAttributeName, params) {
|
|
2
|
+
// Replace the full lineage
|
|
3
|
+
if (!params.startingFromId) {
|
|
4
|
+
return replaceEntireLineage(doc, lineageAttributeName, params.replaceWith);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const selectedFunction = params.merge ? replaceLineageForMerge : replaceLineageForMove;
|
|
8
|
+
return selectedFunction(doc, lineageAttributeName, params);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function replaceLineageForMove(doc, lineageAttributeName, params) {
|
|
12
|
+
let currentElement = doc[lineageAttributeName];
|
|
13
|
+
while (currentElement) {
|
|
14
|
+
if (currentElement?._id === params.startingFromId) {
|
|
15
|
+
return replaceEntireLineage(currentElement, 'parent', params.replaceWith);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
currentElement = currentElement.parent;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function replaceLineageForMerge(doc, lineageAttributeName, params) {
|
|
25
|
+
let currentElement = doc;
|
|
26
|
+
let currentAttributeName = lineageAttributeName;
|
|
27
|
+
while (currentElement) {
|
|
28
|
+
if (currentElement[currentAttributeName]?._id === params.startingFromId) {
|
|
29
|
+
return replaceEntireLineage(currentElement, currentAttributeName, params.replaceWith);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
currentElement = currentElement[currentAttributeName];
|
|
33
|
+
currentAttributeName = 'parent';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function replaceEntireLineage(replaceInDoc, lineageAttributeName, replaceWith) {
|
|
40
|
+
if (!replaceWith) {
|
|
41
|
+
const lineageWasDeleted = !!replaceInDoc[lineageAttributeName];
|
|
42
|
+
replaceInDoc[lineageAttributeName] = undefined;
|
|
43
|
+
return lineageWasDeleted;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
replaceInDoc[lineageAttributeName] = replaceWith;
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = {
|
|
51
|
+
/**
|
|
52
|
+
* Given a doc, replace the parent's lineage
|
|
53
|
+
*
|
|
54
|
+
* @param {Object} doc A CouchDB document containing a parent lineage (eg. parent.parent._id)
|
|
55
|
+
* @param {Object} params
|
|
56
|
+
* @param {Object} params.replaceWith The new hierarchy { parent: { _id: 'parent', parent: { _id: 'grandparent' } }
|
|
57
|
+
* @param {string} params.startingFromId Only the part of the lineage "after" this id will be replaced
|
|
58
|
+
* @param {boolean} params.merge When true, startingFromId is replaced and when false, startingFromId's parent is replaced
|
|
59
|
+
*/
|
|
60
|
+
replaceParentLineage: (doc, params) => {
|
|
61
|
+
return replaceLineage(doc, 'parent', params);
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Given a doc, replace the contact's lineage
|
|
66
|
+
*
|
|
67
|
+
* @param {Object} doc A CouchDB document containing a contact lineage (eg. contact.parent._id)
|
|
68
|
+
* @param {Object} params
|
|
69
|
+
* @param {Object} params.replaceWith The new hierarchy { parent: { _id: 'parent', parent: { _id: 'grandparent' } }
|
|
70
|
+
* @param {string} params.startingFromId Only the part of the lineage "after" this id will be replaced
|
|
71
|
+
* @param {boolean} params.merge When true, startingFromId is replaced and when false, startingFromId's parent is replaced
|
|
72
|
+
*/
|
|
73
|
+
replaceContactLineage: (doc, params) => {
|
|
74
|
+
return replaceLineage(doc, 'contact', params);
|
|
75
|
+
},
|
|
76
|
+
};
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
const log = require('./log');
|
|
2
|
-
const { trace } = log;
|
|
3
|
-
|
|
4
|
-
const { pluckIdsFromLineage } = require('./lineage-manipulation');
|
|
5
|
-
|
|
6
|
-
const lineageConstraints = async (repository, parentDoc) => {
|
|
7
|
-
let mapTypeToAllowedParents;
|
|
8
|
-
try {
|
|
9
|
-
const { settings } = await repository.get('settings');
|
|
10
|
-
const { contact_types } = settings;
|
|
11
|
-
|
|
12
|
-
if (Array.isArray(contact_types)) {
|
|
13
|
-
trace('Found app_settings.contact_types. Configurable hierarchy constraints will be enforced.');
|
|
14
|
-
mapTypeToAllowedParents = contact_types
|
|
15
|
-
.filter(rule => rule)
|
|
16
|
-
.reduce((agg, curr) => Object.assign(agg, { [curr.id]: curr.parents }), {});
|
|
17
|
-
}
|
|
18
|
-
} catch (err) {
|
|
19
|
-
if (err.name !== 'not_found') {
|
|
20
|
-
throw err;
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
if (!mapTypeToAllowedParents) {
|
|
25
|
-
trace('Default hierarchy constraints will be enforced.');
|
|
26
|
-
mapTypeToAllowedParents = {
|
|
27
|
-
district_hospital: [],
|
|
28
|
-
health_center: ['district_hospital'],
|
|
29
|
-
clinic: ['health_center'],
|
|
30
|
-
person: ['district_hospital', 'health_center', 'clinic'],
|
|
31
|
-
};
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
return {
|
|
35
|
-
getHierarchyErrors: contactDoc => getHierarchyViolations(mapTypeToAllowedParents, contactDoc, parentDoc),
|
|
36
|
-
getPrimaryContactViolations: (contactDoc, descendantDocs) => getPrimaryContactViolations(repository, contactDoc, parentDoc, descendantDocs),
|
|
37
|
-
};
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
/*
|
|
41
|
-
Enforce the list of allowed parents for each contact type
|
|
42
|
-
Ensure we are not creating a circular hierarchy
|
|
43
|
-
*/
|
|
44
|
-
const getHierarchyViolations = (mapTypeToAllowedParents, contactDoc, parentDoc) => {
|
|
45
|
-
const getContactType = doc => doc && (doc.type === 'contact' ? doc.contact_type : doc.type);
|
|
46
|
-
const contactType = getContactType(contactDoc);
|
|
47
|
-
const parentType = getContactType(parentDoc);
|
|
48
|
-
if (!contactType) return 'contact required attribute "type" is undefined';
|
|
49
|
-
if (parentDoc && !parentType) return `parent contact "${parentDoc._id}" required attribute "type" is undefined`;
|
|
50
|
-
if (!mapTypeToAllowedParents) return 'hierarchy constraints are undefined';
|
|
51
|
-
|
|
52
|
-
const rulesForContact = mapTypeToAllowedParents[contactType];
|
|
53
|
-
if (!rulesForContact) return `cannot move contact with unknown type '${contactType}'`;
|
|
54
|
-
|
|
55
|
-
const isPermittedMoveToRoot = !parentDoc && rulesForContact.length === 0;
|
|
56
|
-
if (!isPermittedMoveToRoot && !rulesForContact.includes(parentType)) return `contacts of type '${contactType}' cannot have parent of type '${parentType}'`;
|
|
57
|
-
|
|
58
|
-
if (parentDoc && contactDoc._id) {
|
|
59
|
-
const parentAncestry = [parentDoc._id, ...pluckIdsFromLineage(parentDoc.parent)];
|
|
60
|
-
if (parentAncestry.includes(contactDoc._id)) {
|
|
61
|
-
return `Circular hierarchy: Cannot set parent of contact '${contactDoc._id}' as it would create a circular hierarchy.`;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
/*
|
|
67
|
-
A place's primary contact must be a descendant of that place.
|
|
68
|
-
|
|
69
|
-
1. Check to see which part of the contact's lineage will be removed
|
|
70
|
-
2. For each removed part of the contact's lineage, confirm that place's primary contact isn't being removed.
|
|
71
|
-
*/
|
|
72
|
-
const getPrimaryContactViolations = async (repository, contactDoc, parentDoc, descendantDocs) => {
|
|
73
|
-
const safeGetLineageFromDoc = doc => doc ? pluckIdsFromLineage(doc.parent) : [];
|
|
74
|
-
const contactsLineageIds = safeGetLineageFromDoc(contactDoc);
|
|
75
|
-
const parentsLineageIds = safeGetLineageFromDoc(parentDoc);
|
|
76
|
-
|
|
77
|
-
if (parentDoc) {
|
|
78
|
-
parentsLineageIds.push(parentDoc._id);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const docIdsRemovedFromContactLineage = contactsLineageIds.filter(value => !parentsLineageIds.includes(value));
|
|
82
|
-
const docsRemovedFromContactLineage = await repository.allDocs({
|
|
83
|
-
keys: docIdsRemovedFromContactLineage,
|
|
84
|
-
include_docs: true,
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
const primaryContactIds = docsRemovedFromContactLineage.rows
|
|
88
|
-
.map(row => row.doc && row.doc.contact && row.doc.contact._id)
|
|
89
|
-
.filter(id => id);
|
|
90
|
-
|
|
91
|
-
return descendantDocs.find(descendant => primaryContactIds.some(primaryId => descendant._id === primaryId));
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
module.exports = lineageConstraints;
|
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
|
|
2
|
-
/*
|
|
3
|
-
Given a doc, replace the lineage information therein with "replaceWith"
|
|
4
|
-
|
|
5
|
-
startingFromIdInLineage (optional) - Will result in a partial replacement of the lineage. Only the part of the lineage "after" the parent
|
|
6
|
-
with _id=startingFromIdInLineage will be replaced by "replaceWith"
|
|
7
|
-
*/
|
|
8
|
-
const replaceLineage = (doc, lineageAttributeName, replaceWith, startingFromIdInLineage) => {
|
|
9
|
-
const handleReplacement = (replaceInDoc, docAttr, replaceWith) => {
|
|
10
|
-
if (!replaceWith) {
|
|
11
|
-
const lineageWasDeleted = !!replaceInDoc[docAttr];
|
|
12
|
-
replaceInDoc[docAttr] = undefined;
|
|
13
|
-
return lineageWasDeleted;
|
|
14
|
-
} else if (replaceInDoc[docAttr]) {
|
|
15
|
-
replaceInDoc[docAttr]._id = replaceWith._id;
|
|
16
|
-
replaceInDoc[docAttr].parent = replaceWith.parent;
|
|
17
|
-
} else {
|
|
18
|
-
replaceInDoc[docAttr] = replaceWith;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
return true;
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
// Replace the full lineage
|
|
25
|
-
if (!startingFromIdInLineage) {
|
|
26
|
-
return handleReplacement(doc, lineageAttributeName, replaceWith);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// Replace part of a lineage
|
|
30
|
-
let currentParent = doc[lineageAttributeName];
|
|
31
|
-
while (currentParent) {
|
|
32
|
-
if (currentParent._id === startingFromIdInLineage) {
|
|
33
|
-
return handleReplacement(currentParent, 'parent', replaceWith);
|
|
34
|
-
}
|
|
35
|
-
currentParent = currentParent.parent;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
return false;
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
/*
|
|
42
|
-
Function borrowed from shared-lib/lineage
|
|
43
|
-
*/
|
|
44
|
-
const minifyLineagesInDoc = doc => {
|
|
45
|
-
const minifyLineage = lineage => {
|
|
46
|
-
if (!lineage || !lineage._id) {
|
|
47
|
-
return undefined;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const result = {
|
|
51
|
-
_id: lineage._id,
|
|
52
|
-
parent: minifyLineage(lineage.parent),
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
return result;
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
if (!doc) {
|
|
59
|
-
return undefined;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
if ('parent' in doc) {
|
|
63
|
-
doc.parent = minifyLineage(doc.parent);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
if ('contact' in doc) {
|
|
67
|
-
doc.contact = minifyLineage(doc.contact);
|
|
68
|
-
if (doc.contact && !doc.contact.parent) delete doc.contact.parent; // for unit test clarity
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
if (doc.type === 'data_record') {
|
|
72
|
-
delete doc.patient;
|
|
73
|
-
}
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
const createLineageFromDoc = doc => {
|
|
77
|
-
if (!doc) {
|
|
78
|
-
return undefined;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
return {
|
|
82
|
-
_id: doc._id,
|
|
83
|
-
parent: doc.parent || undefined,
|
|
84
|
-
};
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
/*
|
|
88
|
-
Given a lineage, return the ids therein
|
|
89
|
-
*/
|
|
90
|
-
const pluckIdsFromLineage = lineage => {
|
|
91
|
-
const result = [];
|
|
92
|
-
|
|
93
|
-
let current = lineage;
|
|
94
|
-
while (current) {
|
|
95
|
-
result.push(current._id);
|
|
96
|
-
current = current.parent;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
return result;
|
|
100
|
-
};
|
|
101
|
-
|
|
102
|
-
module.exports = {
|
|
103
|
-
createLineageFromDoc,
|
|
104
|
-
minifyLineagesInDoc,
|
|
105
|
-
pluckIdsFromLineage,
|
|
106
|
-
replaceLineage,
|
|
107
|
-
};
|