diff --git a/.gitignore b/.gitignore
index 463f068ee7f..3fd5618f336 100644
--- a/.gitignore
+++ b/.gitignore
@@ -43,6 +43,7 @@ config/*/forms/contact/*.xlsx
tests/integration/results/
allure*
tests/config-temp
+tests/utils/config-temp
.eslintcache
doc-conflicts/
cht-docker-compose.log
diff --git a/tests/e2e/default/contacts/forms/family-with-attachments-create.xml b/tests/e2e/default/contacts/forms/family-with-attachments-create.xml
new file mode 100644
index 00000000000..3cfb669033a
--- /dev/null
+++ b/tests/e2e/default/contacts/forms/family-with-attachments-create.xml
@@ -0,0 +1,112 @@
+
+
+
+ New Family With Attachments
+
+
+
+
+ Family Name
+
+
+ Family Photo
+
+
+ Primary Contact Name
+
+
+ Primary Contact Photo
+
+
+ Children
+
+
+ Child Name
+
+
+ Child Photo
+
+
+
+
+
+
+ PARENT
+ contact
+ family_with_attachments
+
+
+ iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=
+ NEW
+
+
+ PARENT
+ person
+
+
+ iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=
+
+
+
+ PARENT
+ person
+
+
+ iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/e2e/default/contacts/forms/family-with-attachments-edit.xml b/tests/e2e/default/contacts/forms/family-with-attachments-edit.xml
new file mode 100644
index 00000000000..64a00bf14c4
--- /dev/null
+++ b/tests/e2e/default/contacts/forms/family-with-attachments-edit.xml
@@ -0,0 +1,82 @@
+
+
+
+ Edit Family With Attachments
+
+
+
+
+ Family Name
+
+
+ Family Photo
+
+
+ Children
+
+
+ Child Name
+
+
+ Child Photo
+
+
+
+
+
+
+ PARENT
+ contact
+ family_with_attachments
+
+
+
+
+
+
+ PARENT
+ person
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/e2e/default/contacts/sub-contact-attachments.wdio-spec.js b/tests/e2e/default/contacts/sub-contact-attachments.wdio-spec.js
new file mode 100644
index 00000000000..815d300f36a
--- /dev/null
+++ b/tests/e2e/default/contacts/sub-contact-attachments.wdio-spec.js
@@ -0,0 +1,248 @@
+const path = require('path');
+const fs = require('fs');
+const utils = require('@utils');
+const placeFactory = require('@factories/cht/contacts/place');
+const userFactory = require('@factories/cht/users/users');
+const loginPage = require('@page-objects/default/login/login.wdio.page');
+const commonPage = require('@page-objects/default/common/common.wdio.page');
+const commonEnketoPage = require('@page-objects/default/enketo/common-enketo.wdio.page');
+const genericForm = require('@page-objects/default/enketo/generic-form.wdio.page');
+const contactPage = require('@page-objects/default/contacts/contacts.wdio.page');
+const { CONTACT_TYPES } = require('@medic/constants');
+
+describe('Sub-contact attachment routing', () => {
+ const familyPhotoPath = path.join(__dirname, '../enketo/images/photo-for-upload-form.png');
+ const primaryContactPhotoPath = path.join(__dirname, '../../../../webapp/src/img/layers.png');
+ const childPhotoPath = path.join(__dirname, '../../../../webapp/src/img/icon.png');
+
+ const places = placeFactory.generateHierarchy();
+ const healthCenter = places.get(CONTACT_TYPES.HEALTH_CENTER);
+
+ const onlineUser = userFactory.build({
+ place: healthCenter._id,
+ roles: ['program_officer']
+ });
+
+ const familyType = {
+ id: 'family_with_attachments',
+ name_key: 'contact.type.family_with_attachments',
+ group_key: 'contact.type.family_with_attachments.plural',
+ create_key: 'contact.type.family_with_attachments.new',
+ edit_key: 'contact.type.family_with_attachments.edit',
+ primary_contact_key: '',
+ parents: [CONTACT_TYPES.HEALTH_CENTER, CONTACT_TYPES.CLINIC, 'district_hospital'],
+ icon: 'medic-clinic',
+ create_form: 'form:contact:family_with_attachments:create',
+ edit_form: 'form:contact:family_with_attachments:edit',
+ person: false
+ };
+
+ const translations = {
+ 'contact.type.family_with_attachments': 'Family With Attachments',
+ 'contact.type.family_with_attachments.plural': 'Families With Attachments',
+ 'contact.type.family_with_attachments.new': 'New Family With Attachments',
+ 'contact.type.family_with_attachments.edit': 'Edit Family With Attachments'
+ };
+
+ const createFormXml = fs.readFileSync(
+ path.join(__dirname, 'forms/family-with-attachments-create.xml'),
+ 'utf8'
+ );
+
+ const editFormXml = fs.readFileSync(
+ path.join(__dirname, 'forms/family-with-attachments-edit.xml'),
+ 'utf8'
+ );
+
+ const createFormDoc = {
+ _id: 'form:contact:family_with_attachments:create',
+ internalId: 'contact:family_with_attachments:create',
+ title: 'New Family With Attachments',
+ type: 'form',
+ _attachments: {
+ xml: {
+ content_type: 'application/octet-stream',
+ data: Buffer.from(createFormXml).toString('base64'),
+ }
+ }
+ };
+
+ const editFormDoc = {
+ _id: 'form:contact:family_with_attachments:edit',
+ internalId: 'contact:family_with_attachments:edit',
+ title: 'Edit Family With Attachments',
+ type: 'form',
+ _attachments: {
+ xml: {
+ content_type: 'application/octet-stream',
+ data: Buffer.from(editFormXml).toString('base64'),
+ }
+ }
+ };
+
+ const fetchFamilyAndChildren = async (familyId) => {
+ const family = await utils.getDoc(familyId);
+ const [familyDoc, primaryContact] = await utils.getDocs([familyId, family.contact._id]);
+ const allRows = await utils.db.allDocs({ include_docs: true });
+ const repeatChildren = allRows.rows
+ .map(row => row.doc)
+ .filter(doc => doc?.parent?._id === familyId && doc._id !== primaryContact._id);
+ return { family: familyDoc, primaryContact, repeatChildren };
+ };
+
+ const submitFamilyForm = async ({ familyName, primaryContactName, repeatChildren }) => {
+ await commonEnketoPage.setInputValue('Family Name', familyName);
+ await commonEnketoPage.addFileInputValue('Family Photo', familyPhotoPath);
+
+ await commonEnketoPage.setInputValue('Primary Contact Name', primaryContactName);
+ await commonEnketoPage.addFileInputValue('Primary Contact Photo', primaryContactPhotoPath);
+
+ for (let i = 0; i < repeatChildren.length; i++) {
+ await commonEnketoPage.addRepeatSection();
+ await commonEnketoPage.setInputValue('Child Name', repeatChildren[i].name);
+ await commonEnketoPage.addFileInputValue('Child Photo', repeatChildren[i].photoPath, { repeatIndex: i });
+ }
+
+ await genericForm.submitForm();
+ await commonPage.waitForPageLoaded();
+ await contactPage.waitForContactLoaded();
+ };
+
+ before(async () => {
+ await utils.saveDocs([...places.values()]);
+ await utils.createUsers([onlineUser]);
+ await utils.addTranslations('en', translations);
+
+ const settings = await utils.getSettings();
+ settings.contact_types.push(familyType);
+ await utils.updateSettings({ contact_types: settings.contact_types }, { ignoreReload: true });
+
+ await utils.saveDocs([createFormDoc, editFormDoc]);
+
+ await loginPage.login(onlineUser);
+ });
+
+ after(async () => {
+ await utils.deleteDocs([createFormDoc._id, editFormDoc._id]);
+ await utils.revertDb([/^form:/], true);
+ });
+
+ afterEach(async () => {
+ await commonPage.goToPeople();
+ await commonPage.waitForPageLoaded();
+ });
+
+ it('should route uploads to main, sibling, and repeat docs', async () => {
+ await commonPage.goToPeople(healthCenter._id);
+ await commonPage.clickFastActionFAB({ actionId: familyType.id });
+
+ await submitFamilyForm({
+ familyName: 'Routing Family',
+ primaryContactName: 'Amina',
+ repeatChildren: [{ name: 'Kid Alpha', photoPath: childPhotoPath }],
+ });
+
+ const familyId = await contactPage.getCurrentContactId();
+ expect(familyId).to.exist;
+
+ const { family, primaryContact, repeatChildren } = await fetchFamilyAndChildren(familyId);
+
+ expect(family.name).to.equal('Routing Family');
+ expect(family._attachments).to.exist;
+ const familyAttachmentNames = Object.keys(family._attachments);
+ // Two attachments per owner doc: the file-widget upload and the inline-
+ // binary `badge` from the form's instance default. The badge field value is
+ // its attachment name minus the `user-file-` prefix.
+ expect(familyAttachmentNames).to.have.lengthOf(2);
+ const familyBadge = familyAttachmentNames.find(n => n.startsWith('user-file-') && n.endsWith('/badge'));
+ expect(familyBadge, 'family badge attachment').to.exist;
+ expect(familyAttachmentNames.find(n => /^user-file-photo-for-upload-form.*\.png$/.test(n))).to.exist;
+ expect(family.badge).to.equal(familyBadge.replace('user-file-', ''));
+
+ expect(primaryContact.name).to.equal('Amina');
+ expect(primaryContact._attachments).to.exist;
+ const primaryAttachmentNames = Object.keys(primaryContact._attachments);
+ expect(primaryAttachmentNames).to.have.lengthOf(2);
+ const primaryBadge = primaryAttachmentNames.find(n => n.startsWith('user-file-') && n.endsWith('/badge'));
+ expect(primaryBadge, 'primary contact badge attachment').to.exist;
+ expect(primaryAttachmentNames.find(n => /^user-file-layers.*\.png$/.test(n))).to.exist;
+ expect(primaryContact.badge).to.equal(primaryBadge.replace('user-file-', ''));
+
+ expect(repeatChildren).to.have.lengthOf(1);
+ const [child] = repeatChildren;
+ expect(child.name).to.equal('Kid Alpha');
+ expect(child._attachments).to.exist;
+ const childAttachmentNames = Object.keys(child._attachments);
+ expect(childAttachmentNames).to.have.lengthOf(2);
+ const childBadge = childAttachmentNames.find(n => n.startsWith('user-file-') && n.endsWith('/badge'));
+ expect(childBadge, 'child badge attachment').to.exist;
+ expect(childAttachmentNames.find(n => /^user-file-icon.*\.png$/.test(n))).to.exist;
+ expect(child.badge).to.equal(childBadge.replace('user-file-', ''));
+
+ const allAttachmentNames = [
+ ...familyAttachmentNames,
+ ...primaryAttachmentNames,
+ ...childAttachmentNames,
+ ];
+ expect(new Set(allAttachmentNames).size).to.equal(6);
+ });
+
+ it('should keep saved attachments intact when adding a new repeat child on edit', async () => {
+ await commonPage.goToPeople(healthCenter._id);
+ await commonPage.clickFastActionFAB({ actionId: familyType.id });
+
+ await submitFamilyForm({
+ familyName: 'Edit Family',
+ primaryContactName: 'Bilal',
+ repeatChildren: [{ name: 'Kid One', photoPath: childPhotoPath }],
+ });
+
+ const familyId = await contactPage.getCurrentContactId();
+ const before = await fetchFamilyAndChildren(familyId);
+
+ const snapshotAttachments = (doc) => Object.fromEntries(
+ Object.entries(doc._attachments || {}).map(([key, value]) => [key, value.length])
+ );
+ const beforeFamily = snapshotAttachments(before.family);
+ const beforePrimary = snapshotAttachments(before.primaryContact);
+ expect(before.repeatChildren).to.have.lengthOf(1);
+ const beforeKidOne = snapshotAttachments(before.repeatChildren[0]);
+
+ await commonPage.accessEditOption();
+ await commonPage.waitForPageLoaded();
+
+ await commonEnketoPage.addRepeatSection();
+ await commonEnketoPage.setInputValue('Child Name', 'Kid Two');
+ await commonEnketoPage.addFileInputValue('Child Photo', familyPhotoPath, { repeatIndex: 0 });
+
+ await genericForm.submitForm();
+ await commonPage.waitForPageLoaded();
+ await contactPage.waitForContactLoaded();
+
+ const after = await fetchFamilyAndChildren(familyId);
+
+ expect(snapshotAttachments(after.family)).to.deep.equal(beforeFamily);
+ expect(snapshotAttachments(after.primaryContact)).to.deep.equal(beforePrimary);
+
+ // The edit form has no , so no save-time attach happens for it and
+ // the original badge attachment + field value survive the edit unchanged.
+ expect(after.family.badge).to.equal(before.family.badge);
+ expect(after.primaryContact.badge).to.equal(before.primaryContact.badge);
+
+ const kidOneAfter = after.repeatChildren.find(c => c.name === 'Kid One');
+ const kidTwoAfter = after.repeatChildren.find(c => c.name === 'Kid Two');
+ expect(kidOneAfter, 'Kid One should still exist').to.exist;
+ expect(kidTwoAfter, 'Kid Two should be created').to.exist;
+
+ expect(snapshotAttachments(kidOneAfter)).to.deep.equal(beforeKidOne);
+ const kidOneBefore = before.repeatChildren.find(c => c.name === 'Kid One');
+ expect(kidOneAfter.badge).to.equal(kidOneBefore.badge);
+
+ // Kid Two is created on edit — the edit form has no badge default for
+ // children, so the new repeat child has no badge attachment.
+ expect(kidTwoAfter._attachments).to.exist;
+ const kidTwoAttachmentNames = Object.keys(kidTwoAfter._attachments);
+ expect(kidTwoAttachmentNames).to.have.lengthOf(1);
+ expect(kidTwoAttachmentNames[0]).to.match(/^user-file-photo-for-upload-form.*\.png$/);
+ });
+});
diff --git a/tests/e2e/default/enketo/forms/db-doc-file-upload.xml b/tests/e2e/default/enketo/forms/db-doc-file-upload.xml
new file mode 100644
index 00000000000..99cdb0e021d
--- /dev/null
+++ b/tests/e2e/default/enketo/forms/db-doc-file-upload.xml
@@ -0,0 +1,41 @@
+
+
+
+ db-doc-file-upload
+
+
+
+
+
+
+
+ sub_record
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/e2e/default/enketo/forms/db-doc-multi-file-upload.xml b/tests/e2e/default/enketo/forms/db-doc-multi-file-upload.xml
new file mode 100644
index 00000000000..231549f827d
--- /dev/null
+++ b/tests/e2e/default/enketo/forms/db-doc-multi-file-upload.xml
@@ -0,0 +1,50 @@
+
+
+
+ db-doc-multi-file-upload
+
+
+
+
+
+
+
+ sub_record_a
+
+
+
+
+ sub_record_b
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/e2e/default/enketo/forms/db-doc-repeat-file-upload.xml b/tests/e2e/default/enketo/forms/db-doc-repeat-file-upload.xml
new file mode 100644
index 00000000000..90479783342
--- /dev/null
+++ b/tests/e2e/default/enketo/forms/db-doc-repeat-file-upload.xml
@@ -0,0 +1,40 @@
+
+
+
+ db-doc-repeat-file-upload
+
+
+
+
+
+
+
+ repeat_record
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/e2e/default/enketo/submit-db-doc-file-upload.wdio-spec.js b/tests/e2e/default/enketo/submit-db-doc-file-upload.wdio-spec.js
new file mode 100644
index 00000000000..b43d9b5f5d3
--- /dev/null
+++ b/tests/e2e/default/enketo/submit-db-doc-file-upload.wdio-spec.js
@@ -0,0 +1,180 @@
+const commonPage = require('@page-objects/default/common/common.wdio.page');
+const commonEnketoPage = require('@page-objects/default/enketo/common-enketo.wdio.page');
+const reportsPage = require('@page-objects/default/reports/reports.wdio.page');
+const utils = require('@utils');
+const path = require('path');
+const loginPage = require('@page-objects/default/login/login.wdio.page');
+const genericForm = require('@page-objects/default/enketo/generic-form.wdio.page');
+
+describe('Submit form with file uploads routed to db-doc sub-documents (#10904)', () => {
+ const mainImagePath = path.join(__dirname, '/images/photo-for-upload-form.png');
+ const subImagePath = path.join(__dirname, '../../../../webapp/src/img/layers.png');
+ const subImagePath2 = path.join(__dirname, '../../../../webapp/src/img/icon.png');
+
+ before(async () => {
+ await utils.saveDocIfNotExists(commonPage.createFormDoc(`${__dirname}/forms/db-doc-file-upload`));
+ await utils.saveDocIfNotExists(commonPage.createFormDoc(`${__dirname}/forms/db-doc-multi-file-upload`));
+ await utils.saveDocIfNotExists(commonPage.createFormDoc(`${__dirname}/forms/db-doc-repeat-file-upload`));
+ await loginPage.cookieLogin();
+ await commonPage.hideSnackbar();
+ });
+
+ it('should route file attachments to the correct owner doc', async () => {
+ await commonPage.goToReports();
+ await commonPage.openFastActionReport('db-doc-file-upload', false);
+
+ await commonEnketoPage.setInputValue('Name', 'Test Sub Doc Upload');
+ await commonEnketoPage.addFileInputValue('Main photo', mainImagePath);
+ await commonEnketoPage.addFileInputValue('Sub doc photo', subImagePath);
+
+ await genericForm.submitForm();
+ await commonPage.waitForPageLoaded();
+
+ const reportId = await reportsPage.getCurrentReportId();
+ const report = await utils.getDoc(reportId);
+
+ expect(report.fields.name).to.equal('Test Sub Doc Upload');
+
+ // The main doc should have the main_photo attachment (xpath-based name for binary widget)
+ const mainAttachments = Object.keys(report._attachments || {});
+ const mainPhotoAttachment = mainAttachments.find(name => name.match(/^user-file-photo-for-upload-form.*\.png$/));
+ expect(mainPhotoAttachment, 'Main photo should be attached to the report doc').to.exist;
+
+ // The sub-doc ID is stored via db-doc-ref
+ const subDocId = report.fields.sub_doc_ref;
+ expect(subDocId, 'Sub-doc reference should be populated by db-doc-ref').to.exist;
+
+ // Fetch the sub-doc and verify it has the sub_photo attachment
+ const subDoc = await utils.getDoc(subDocId);
+ expect(subDoc, 'Sub-doc should exist in the database').to.exist;
+ expect(subDoc.type).to.equal('sub_record');
+
+ const subAttachments = Object.keys(subDoc._attachments || {});
+ const subPhotoAttachment = subAttachments.find(name => name.match(/^user-file-layers.*\.png$/));
+ expect(subPhotoAttachment, 'Sub-doc photo should be attached to the sub-doc').to.exist;
+ expect(subDoc._attachments[subPhotoAttachment].content_type).to.equal('image/png');
+
+ // The sub-doc file should NOT be on the main doc
+ const mainHasSubFile = mainAttachments.find(name => name.match(/layers/));
+ expect(mainHasSubFile, 'Sub-doc file should not appear on the main doc').to.not.exist;
+ });
+
+ it('should route file attachments correctly with multiple sub-docs', async () => {
+ await commonPage.goToReports();
+ await commonPage.openFastActionReport('db-doc-multi-file-upload', false);
+
+ await commonEnketoPage.setInputValue('Name', 'Test Multi Sub Doc');
+ await commonEnketoPage.addFileInputValue('Main photo', mainImagePath);
+ await commonEnketoPage.addFileInputValue('Sub doc A photo', subImagePath);
+ await commonEnketoPage.addFileInputValue('Sub doc B photo', subImagePath2);
+
+ await genericForm.submitForm();
+ await commonPage.waitForPageLoaded();
+
+ const reportId = await reportsPage.getCurrentReportId();
+ const report = await utils.getDoc(reportId);
+
+ expect(report.fields.name).to.equal('Test Multi Sub Doc');
+
+ const mainAttachments = Object.keys(report._attachments || {});
+ const mainPhotoAttachment = mainAttachments.find(name => name.match(/^user-file-photo-for-upload-form.*\.png$/));
+ expect(mainPhotoAttachment, 'Main photo should be attached to the report doc').to.exist;
+
+ // Verify sub-doc A
+ const subDocAId = report.fields.sub_doc_a_ref;
+ expect(subDocAId, 'Sub-doc A reference should be populated').to.exist;
+
+ const subDocA = await utils.getDoc(subDocAId);
+ expect(subDocA.type).to.equal('sub_record_a');
+
+ const subAAttachments = Object.keys(subDocA._attachments || {});
+ const subAPhoto = subAAttachments.find(name => name.match(/^user-file-layers.*\.png$/));
+ expect(subAPhoto, 'Sub-doc A photo should be attached to sub-doc A').to.exist;
+ expect(subDocA._attachments[subAPhoto].content_type).to.equal('image/png');
+
+ // Verify sub-doc B
+ const subDocBId = report.fields.sub_doc_b_ref;
+ expect(subDocBId, 'Sub-doc B reference should be populated').to.exist;
+
+ const subDocB = await utils.getDoc(subDocBId);
+ expect(subDocB.type).to.equal('sub_record_b');
+
+ const subBAttachments = Object.keys(subDocB._attachments || {});
+ const subBPhoto = subBAttachments.find(name => name.match(/^user-file-icon.*\.png$/));
+ expect(subBPhoto, 'Sub-doc B photo should be attached to sub-doc B').to.exist;
+ expect(subDocB._attachments[subBPhoto].content_type).to.equal('image/png');
+
+ // Cross-contamination checks: no sub-doc files on main doc
+ const mainHasSubAFile = mainAttachments.find(name => name.match(/layers/));
+ expect(mainHasSubAFile, 'Sub-doc A file should not appear on the main doc').to.not.exist;
+ const mainHasSubBFile = mainAttachments.find(name => name.match(/icon/));
+ expect(mainHasSubBFile, 'Sub-doc B file should not appear on the main doc').to.not.exist;
+
+ // No cross-contamination between sub-docs
+ const subAHasSubBFile = subAAttachments.find(name => name.match(/icon/));
+ expect(subAHasSubBFile, 'Sub-doc B file should not appear on sub-doc A').to.not.exist;
+ const subBHasSubAFile = subBAttachments.find(name => name.match(/layers/));
+ expect(subBHasSubAFile, 'Sub-doc A file should not appear on sub-doc B').to.not.exist;
+ });
+
+ it('should route file attachments to sub-docs inside repeat groups', async () => {
+ await commonPage.goToReports();
+ await commonPage.openFastActionReport('db-doc-repeat-file-upload', false);
+
+ await commonEnketoPage.setInputValue('Name', 'Test Repeat Sub Doc');
+
+ // The repeat starts with zero instances, so add the first intance before uploading
+ await commonEnketoPage.addRepeatSection();
+ await commonEnketoPage.addFileInputValue('Repeat photo', mainImagePath, { repeatIndex: 0 });
+
+ // Add a second repeat and upload a different file
+ await commonEnketoPage.addRepeatSection();
+ await commonEnketoPage.addFileInputValue('Repeat photo', subImagePath, { repeatIndex: 1 });
+
+ await genericForm.submitForm();
+ await commonPage.waitForPageLoaded();
+
+ const reportId = await reportsPage.getCurrentReportId();
+ const report = await utils.getDoc(reportId);
+
+ expect(report.fields.name).to.equal('Test Repeat Sub Doc');
+
+ // There should be two repeat sections, each with a db-doc-ref
+ const repeatSections = report.fields.repeat_section;
+ expect(repeatSections).to.be.an('array');
+ expect(repeatSections.length).to.equal(2);
+
+ // Verify first repeat sub-doc
+ const repeatDoc1Id = repeatSections[0].repeat_doc_ref;
+ expect(repeatDoc1Id, 'First repeat doc reference should be populated').to.exist;
+
+ const repeatDoc1 = await utils.getDoc(repeatDoc1Id);
+ expect(repeatDoc1.type).to.equal('repeat_record');
+
+ const repeat1Attachments = Object.keys(repeatDoc1._attachments || {});
+ const repeat1Photo = repeat1Attachments.find(name => name.match(/^user-file-photo-for-upload-form.*\.png$/));
+ expect(repeat1Photo, 'First repeat doc should have its photo attached').to.exist;
+
+ // Verify second repeat sub-doc
+ const repeatDoc2Id = repeatSections[1].repeat_doc_ref;
+ expect(repeatDoc2Id, 'Second repeat doc reference should be populated').to.exist;
+
+ const repeatDoc2 = await utils.getDoc(repeatDoc2Id);
+ expect(repeatDoc2.type).to.equal('repeat_record');
+
+ const repeat2Attachments = Object.keys(repeatDoc2._attachments || {});
+ const repeat2Photo = repeat2Attachments.find(name => name.match(/^user-file-layers.*\.png$/));
+ expect(repeat2Photo, 'Second repeat doc should have its photo attached').to.exist;
+
+ // No file attachments should be on the main report doc
+ const mainAttachments = Object.keys(report._attachments || {});
+ const mainUserFiles = mainAttachments.filter(name => name.startsWith('user-file-'));
+ expect(mainUserFiles, 'No user files should be on the main doc').to.be.empty;
+
+ // No cross-contamination between repeat sub-docs
+ const repeat1HasRepeat2File = repeat1Attachments.find(name => name.match(/layers/));
+ expect(repeat1HasRepeat2File, 'Repeat 2 file should not appear on repeat 1 doc').to.not.exist;
+ const repeat2HasRepeat1File = repeat2Attachments.find(name => name.match(/photo-for-upload-form/));
+ expect(repeat2HasRepeat1File, 'Repeat 1 file should not appear on repeat 2 doc').to.not.exist;
+ });
+});
diff --git a/webapp/src/ts/services/contact-save.service.ts b/webapp/src/ts/services/contact-save.service.ts
index 8d72dabd892..1bd0d37de91 100644
--- a/webapp/src/ts/services/contact-save.service.ts
+++ b/webapp/src/ts/services/contact-save.service.ts
@@ -7,16 +7,26 @@ import { EnketoTranslationService } from '@mm-services/enketo-translation.servic
import { ExtractLineageService } from '@mm-services/extract-lineage.service';
import { AttachmentService } from '@mm-services/attachment.service';
import { CHTDatasourceService } from '@mm-services/cht-datasource.service';
+import { USER_FILE_PREFIX } from '@mm-services/enketo.service';
import { Contact, Qualifier } from '@medic/cht-datasource';
import { Xpath } from '@mm-providers/xpath-element-path.provider';
import FileManager from '../../js/enketo/file-manager';
+interface ContactOwnerContext {
+ root: Element;
+ preparedDocs: Record[];
+ mainDoc: Record;
+ submittedRepeatsLen: number;
+}
+
+type AttachmentNamesByDoc = Map, Set>;
+type FileNameMapByDoc = Map, Map>;
+
@Injectable({
providedIn: 'root'
})
export class ContactSaveService {
private readonly CONTACT_FIELD_NAMES = [ 'parent', 'contact' ];
- private readonly USER_FILE_ATTACHMENT_PREFIX = 'user-file-';
private readonly getContactFromDatasource: ReturnType;
constructor(
@@ -55,7 +65,7 @@ export class ContactSaveService {
// Process attachments for all documents
const preparedDocs = [ doc ].concat(repeated, siblings); // NB: order matters: #4200
- this.processAllAttachments(preparedDocs, xmlStr);
+ this.processAllAttachments(preparedDocs, submitted, xmlStr);
return {
docId: doc._id,
@@ -173,51 +183,241 @@ export class ContactSaveService {
});
}
- private processAllAttachments(preparedDocs: Record[], xmlStr: string) {
- const mainDoc = preparedDocs[0];
- const newAttachmentNames = new Set();
- const fileNameMap = new Map(); // Map of original file name -> sanitized file name
+ private processAllAttachments(
+ preparedDocs: Record[],
+ submitted: { doc; siblings?; repeats? },
+ xmlStr: string,
+ ) {
+ const ctx: ContactOwnerContext = {
+ root: $.parseXML(xmlStr).documentElement,
+ preparedDocs,
+ mainDoc: preparedDocs[0],
+ submittedRepeatsLen: submitted?.repeats?.child_data?.length ?? 0
+ };
+
+ const newAttachmentNamesByDoc: AttachmentNamesByDoc = new Map();
+ const fileNameMapByDoc: FileNameMapByDoc = new Map();
+
+ const fileManagerNames = this.attachUploadedFiles(ctx, newAttachmentNamesByDoc, fileNameMapByDoc);
+ this.attachInlineBinaryFields(ctx, fileManagerNames, newAttachmentNamesByDoc);
+ this.finalizeDocs(preparedDocs, fileNameMapByDoc, newAttachmentNamesByDoc);
+ }
+
+ private trackNewAttachment(map: AttachmentNamesByDoc, doc: Record, name: string) {
+ const set = map.get(doc) ?? new Set();
+ set.add(name);
+ map.set(doc, set);
+ }
+
+ private trackFileName(map: FileNameMapByDoc, doc: Record, original: string, sanitized: string) {
+ const names = map.get(doc) ?? new Map();
+ names.set(original, sanitized);
+ map.set(doc, names);
+ }
- // Attach files from FileManager (uploaded via file widgets)
+ /** Attaches FileManager uploads, routed per sub-doc. Returns the original
+ * filenames so the binary pass can skip upload widgets that also carry
+ * `type="binary"` markup. */
+ private attachUploadedFiles(
+ ctx: ContactOwnerContext,
+ newAttachmentNamesByDoc: AttachmentNamesByDoc,
+ fileNameMapByDoc: FileNameMapByDoc,
+ ): Set {
+ const fileManagerNames = new Set();
FileManager
.getCurrentFiles()
.forEach(file => {
+ const ownerDoc = this.findContactOwnerForFilename(file.name, ctx);
const sanitizedFileName = this.sanitizeFileName(file.name);
- const attachmentName = `${this.USER_FILE_ATTACHMENT_PREFIX}${sanitizedFileName}`;
- newAttachmentNames.add(attachmentName);
- this.attachmentService.add(mainDoc, attachmentName, file, file.type, false);
+ const attachmentName = `${USER_FILE_PREFIX}${sanitizedFileName}`;
- // Track the mapping for field value sanitization
- fileNameMap.set(file.name, sanitizedFileName);
+ this.attachmentService.add(ownerDoc, attachmentName, file, file.type, false);
+ this.trackNewAttachment(newAttachmentNamesByDoc, ownerDoc, attachmentName);
+ this.trackFileName(fileNameMapByDoc, ownerDoc, file.name, sanitizedFileName);
+ fileManagerNames.add(file.name);
});
+ return fileManagerNames;
+ }
- // Process binary fields from XML
- if (xmlStr) {
- const $record = $($.parseXML(xmlStr));
- const formId = $record.find(':first').attr('id');
-
- $record
- .find('[type=binary]')
- .each((_idx, element) => {
- const content = $(element).text();
- if (content) {
- const xpath = Xpath.getElementXPath(element);
- // Replace instance root element node name with form internal ID
- const filename = this.USER_FILE_ATTACHMENT_PREFIX.slice(0, -1) +
- (xpath.startsWith('/' + formId) ? xpath : xpath.replace(/^\/[^/]+/, '/' + formId));
- newAttachmentNames.add(filename);
- this.attachmentService.add(mainDoc, filename, content, 'image/png', true);
- }
- });
+ private attachInlineBinaryFields(
+ ctx: ContactOwnerContext,
+ fileManagerNames: Set,
+ newAttachmentNamesByDoc: AttachmentNamesByDoc,
+ ) {
+ $(ctx.root)
+ .find('[type=binary]')
+ .each((_idx, element: Element) => {
+ this.attachOneBinaryField(element, ctx, fileManagerNames, newAttachmentNamesByDoc);
+ });
+ }
+
+ /** Attaches the blob to the owning doc and mirrors the bare reference into the
+ * doc's field value, so the renderer resolves the image via
+ * `USER_FILE_PREFIX + value`. */
+ private attachOneBinaryField(
+ element: Element,
+ ctx: ContactOwnerContext,
+ fileManagerNames: Set,
+ newAttachmentNamesByDoc: AttachmentNamesByDoc,
+ ) {
+ const content = $(element).text();
+ // Skip: empty; a value that already looks like an attachment reference
+ // (re-saves stay idempotent); or a file-upload widget tagged type="binary"
+ // whose blob the FileManager pass already attached.
+ if (!content || this.enketoTranslationService.isAttachmentRef(content) || fileManagerNames.has(content)) {
+ return;
+ }
+
+ const ownerDoc = this.resolveContactOwnerDoc(element, ctx);
+ const reference = this.computeBinaryReference(element, ctx);
+ const attachmentName = `${USER_FILE_PREFIX}${reference}`;
+
+ this.attachmentService.add(ownerDoc, attachmentName, content, 'image/png', true);
+ this.trackNewAttachment(newAttachmentNamesByDoc, ownerDoc, attachmentName);
+
+ // Field path is the element's position relative to its section container
+ // (the section for main/sibling, the i-th repeat child for a repeat).
+ const container = this.findFieldContainerElement(element, ctx);
+ const fieldPath = container && this.computeFieldPath(element, container);
+ if (fieldPath) {
+ objectPath.set(ownerDoc, fieldPath, reference);
}
+ }
+
+ /** Bare attachment reference for a binary node: the element's xpath with the
+ * instance root swapped for the form internal ID and the leading slash
+ * dropped -> `//`. */
+ private computeBinaryReference(element: Element, ctx: ContactOwnerContext): string {
+ const formId = $(ctx.root).attr('id');
+ const xpath = Xpath.getElementXPath(element);
+ return (xpath.startsWith('/' + formId) ? xpath : xpath.replace(/^\/[^/]+/, '/' + formId)).slice(1);
+ }
- // Sanitize field values in the document to match sanitized attachment names
- this.sanitizeFieldValues(mainDoc, fileNameMap);
+ /** Per-doc field-value sanitization + orphan cleanup. */
+ private finalizeDocs(
+ preparedDocs: Record[],
+ fileNameMapByDoc: FileNameMapByDoc,
+ newAttachmentNamesByDoc: AttachmentNamesByDoc,
+ ) {
+ for (const doc of preparedDocs) {
+ const nameMap = fileNameMapByDoc.get(doc) ?? new Map();
+ this.sanitizeFieldValues(doc, nameMap);
+
+ const newNames = newAttachmentNamesByDoc.get(doc) ?? new Set();
+ const referenced = this.findReferencedAttachments(doc);
+ const valid = new Set([ ...newNames, ...referenced ]);
+ this.removeOrphanedAttachments(doc, valid);
+ }
+ }
- // Remove orphaned user attachments that are no longer referenced
- const referencedAttachmentNames = this.findReferencedAttachments(mainDoc);
- const validAttachmentNames = new Set([...newAttachmentNames, ...referencedAttachmentNames]);
- this.removeOrphanedAttachments(mainDoc, validAttachmentNames);
+ private findSectionForElement(el: Element, root: Element): Element | null {
+ let section: Element = el;
+ while (section.parentNode && section.parentNode !== root) {
+ section = section.parentNode as Element;
+ }
+ return section.parentNode === root ? section : null;
+ }
+
+ private isMainSection(section: Element, root: Element): boolean {
+ const ignored = new Set([ 'meta', 'inputs', 'repeat' ]);
+ const firstSection = (Array.from(root.children) as Element[])
+ .find(c => !ignored.has(c.tagName) && c.childElementCount > 0);
+ return section === firstSection;
+ }
+
+ private findSiblingDoc(
+ section: Element,
+ preparedDocs: Record[],
+ mainDoc: Record,
+ ): Record {
+ const siblingId = mainDoc[section.tagName]?._id;
+ return preparedDocs.find((d: Record) => d._id === siblingId) ?? mainDoc;
+ }
+
+ private findRepeatDoc(
+ section: Element,
+ el: Element,
+ preparedDocs: Record[],
+ submittedRepeatsLen: number,
+ ): Record | null {
+ const repeatChildren = Array.from(section.children) as Element[];
+ const childIdx = repeatChildren.findIndex(c => c.contains(el));
+ if (childIdx >= 0 && childIdx < submittedRepeatsLen) {
+ return preparedDocs[1 + childIdx];
+ }
+ return null;
+ }
+
+ /**
+ * The element whose children map 1:1 to the owner doc's top-level fields:
+ * the section itself for main/sibling, the i-th `` of `` for
+ * repeats. Anchors `computeFieldPath`.
+ */
+ private findFieldContainerElement(el: Element, ctx: ContactOwnerContext): Element | null {
+ const section = this.findSectionForElement(el, ctx.root);
+ if (!section) {
+ return null;
+ }
+ if (section.tagName === 'repeat') {
+ const repeatChildren = Array.from(section.children) as Element[];
+ return repeatChildren.find(c => c.contains(el)) ?? null;
+ }
+ return section;
+ }
+
+ /**
+ * Dot-path of the element's position relative to its container, e.g.
+ * `` → `group.photo`.
+ */
+ private computeFieldPath(el: Element, container: Element): string {
+ const segments: string[] = [];
+ let node: Node | null = el;
+ while (node && node !== container) {
+ segments.unshift((node as Element).tagName);
+ node = node.parentNode;
+ }
+ return segments.join('.');
+ }
+
+ /**
+ * Resolves the prepared sub-doc that owns an XML element by walking up to its
+ * section root (a direct child of the form root):
+ *
+ * - main section (first non-meta/inputs/repeat element child of root) -> preparedDocs[0]
+ * - / peer of main section -> sibling doc with matching _id
+ * - 's i-th -> preparedDocs[1 + i] (repeats precede siblings in concat)
+ * - anything else (meta, inputs, unknown) -> mainDoc
+ */
+ private resolveContactOwnerDoc(el: Element, ctx: ContactOwnerContext): Record {
+ const { root, preparedDocs, mainDoc, submittedRepeatsLen } = ctx;
+ const section = this.findSectionForElement(el, root);
+ if (!section || this.isMainSection(section, root)) {
+ return mainDoc;
+ }
+ if (this.CONTACT_FIELD_NAMES.includes(section.tagName)) {
+ return this.findSiblingDoc(section, preparedDocs, mainDoc);
+ }
+ if (section.tagName === 'repeat') {
+ return this.findRepeatDoc(section, el, preparedDocs, submittedRepeatsLen) ?? mainDoc;
+ }
+ return mainDoc;
+ }
+
+ /**
+ * Resolves the owning prepared doc for a FileManager upload by matching the
+ * filename against `[type=file]` widget text, falling back to mainDoc when no
+ * node matches. Filenames are unique within a form session (Enketo's
+ * timestamp suffix), so the first match is the only match.
+ */
+ private findContactOwnerForFilename(filename: string, ctx: ContactOwnerContext): Record {
+ const match = $(ctx.root)
+ .find('[type=file]')
+ .toArray()
+ .find(el => $(el).text() === filename);
+ if (!match) {
+ return ctx.mainDoc;
+ }
+ return this.resolveContactOwnerDoc(match, ctx);
}
private findReferencedAttachments(doc: Record): Set {
@@ -232,7 +432,7 @@ export class ContactSaveService {
if (typeof value !== 'string' || !value) {
return false;
}
- const possibleAttachmentName = `${this.USER_FILE_ATTACHMENT_PREFIX}${value}`;
+ const possibleAttachmentName = `${USER_FILE_PREFIX}${value}`;
return existingAttachmentNames.includes(possibleAttachmentName);
};
@@ -240,7 +440,7 @@ export class ContactSaveService {
referencedPaths.forEach(path => {
const value = objectPath.get(doc, path);
- referenced.add(`${this.USER_FILE_ATTACHMENT_PREFIX}${value}`);
+ referenced.add(`${USER_FILE_PREFIX}${value}`);
});
return referenced;
@@ -252,7 +452,7 @@ export class ContactSaveService {
}
Object.keys(doc._attachments)
- .filter(name => name.startsWith(this.USER_FILE_ATTACHMENT_PREFIX) && !validAttachmentNames.has(name))
+ .filter(name => name.startsWith(USER_FILE_PREFIX) && !validAttachmentNames.has(name))
.forEach(name => this.attachmentService.remove(doc, name));
}
diff --git a/webapp/src/ts/services/enketo-translation.service.ts b/webapp/src/ts/services/enketo-translation.service.ts
index 1b45cbbca40..7c67af682fd 100644
--- a/webapp/src/ts/services/enketo-translation.service.ts
+++ b/webapp/src/ts/services/enketo-translation.service.ts
@@ -35,16 +35,12 @@ export class EnketoTranslationService {
path = path || '';
const result = {};
this.withElements(data).forEach((n:any) => {
- const typeAttribute = n.attributes.getNamedItem('type');
const updatedPath = path + '/' + n.nodeName;
let value;
const hasChildren = this.withElements(n.childNodes).length > 0;
if (hasChildren) {
value = this.nodesToJs(n.childNodes, repeatPaths, updatedPath);
- } else if (typeAttribute && typeAttribute.value === 'binary') {
- // this is attached to the doc instead of inlined
- value = '';
} else {
value = n.textContent;
}
@@ -91,7 +87,56 @@ export class EnketoTranslationService {
return found;
}
- return elem.children(name);
+ // Match by node name in JS rather than passing `name` to the jQuery selector
+ // data keys can be `_attachments` names containing ':' / '/' (form-id-derived),
+ // which jQuery would reject as an invalid selector.
+ return elem.children().filter((_idx, child) => child.nodeName === name);
+ }
+
+ /**
+ * True for an attachment-reference value: the position-derived name stored for
+ * an inline-binary field after its blob is attached, e.g.
+ * `//` (slash-delimited XML names, no `user-file-`
+ * prefix). Inline base64 never matches: it carries `+`/`=` and isn't a path.
+ */
+ isAttachmentRef(value): boolean {
+ // `:` appears in contact form ids (e.g. `contact:person:create`); base64
+ // never contains `:` so allowing it here doesn't widen the base64 overlap.
+ return typeof value === 'string' && /^[A-Za-z_][\w.:-]*(?:\/[A-Za-z_][\w.:-]*)+$/.test(value);
+ }
+
+ // For [type=binary] nodes the form's instance default / calculate / itext
+ // base64 is the source of truth. Skip the bind when the saved value is empty
+ // (keep the form default) or is an attachment reference (the real data lives
+ // in the attachment). Genuine inline base64 still binds.
+ private shouldSkipBinaryBind(elem, data): boolean {
+ const typeAttr = elem.attr ? elem.attr('type') : elem[0]?.getAttribute?.('type');
+ if (typeAttr !== 'binary') {
+ return false;
+ }
+ return [ null, undefined, '' ].includes(data) || this.isAttachmentRef(data);
+ }
+
+ private bindArrayToXml(elem, data) {
+ const parent = elem.parent();
+ elem.remove();
+
+ data.forEach((dataEntry) => {
+ const clone = elem.clone();
+ this.bindJsonToXml(clone, dataEntry);
+ parent.append(clone);
+ });
+ }
+
+ private bindObjectToXml(elem, data, childMatcher?) {
+ if (!elem.children().length) {
+ this.bindJsonToXml(elem, data._id);
+ }
+
+ Object.keys(data).forEach((key) => {
+ const current = this.findCurrentElement(elem, key, childMatcher);
+ this.bindJsonToXml(current, data[key]);
+ });
}
bindJsonToXml(elem, data, childMatcher?) {
@@ -100,32 +145,21 @@ export class EnketoTranslationService {
elem.removeAttr('jr:template');
elem.removeAttr('template');
+ if (this.shouldSkipBinaryBind(elem, data)) {
+ return;
+ }
+
if (data === null || typeof data !== 'object') {
elem.text(data);
return;
}
if (Array.isArray(data)) {
- const parent = elem.parent();
- elem.remove();
-
- data.forEach((dataEntry) => {
- const clone = elem.clone();
- this.bindJsonToXml(clone, dataEntry);
- parent.append(clone);
- });
+ this.bindArrayToXml(elem, data);
return;
}
- if (!elem.children().length) {
- this.bindJsonToXml(elem, data._id);
- }
-
- Object.keys(data).forEach((key) => {
- const value = data[key];
- const current = this.findCurrentElement(elem, key, childMatcher);
- this.bindJsonToXml(current, value);
- });
+ this.bindObjectToXml(elem, data, childMatcher);
}
getHiddenFieldList (model, dbDocFields:Array) {
diff --git a/webapp/src/ts/services/enketo.service.ts b/webapp/src/ts/services/enketo.service.ts
index 2a219d00e0f..be1cfa1ce1b 100644
--- a/webapp/src/ts/services/enketo.service.ts
+++ b/webapp/src/ts/services/enketo.service.ts
@@ -18,6 +18,14 @@ import { CHTDatasourceService } from '@mm-services/cht-datasource.service';
import { Qualifier, Report } from '@medic/cht-datasource';
import { DOC_TYPES } from '@medic/constants';
+/**
+ * Prefix for every CouchDB attachment created from a form media field, so a
+ * field's value is uniformly resolved as `USER_FILE_PREFIX + value`:
+ * - file-widget uploads -> `user-file-`
+ * - inline-binary fields -> `user-file-//`
+ */
+export const USER_FILE_PREFIX = 'user-file-';
+
/**
* Service for interacting with Enketo forms. This code is intended for displaying forms in the CHT as well as being
* reused by code outside the CHT (e.g. cht-conf-test-harness). All logic that is proper to Enketo functionality should
@@ -362,6 +370,13 @@ export class EnketoService {
return form;
}
+ private findFileNodeByFilename($record, filename: string) {
+ return $record
+ .find('[type=file]')
+ .toArray()
+ .find((element) => $(element).text() === filename) ?? $record[0];
+ }
+
private xmlToDocs(doc, formXml, xmlVersion, record) {
const recordDoc = $.parseXML(record);
const $record = $($(recordDoc).children()[0]);
@@ -465,15 +480,17 @@ export class EnketoService {
$element.text(refId);
});
- const docsToStore = $record
- .find('[db-doc=true]')
- .map((idx, element) => {
- const docToStore: any = this.enketoTranslationService.reportRecordToJs(getOuterHTML(element));
- docToStore._id = getId(Xpath.getElementXPath(element));
- docToStore.reported_date = Date.now();
- return docToStore;
- })
- .get();
+ // Sub-docs are parsed twice: first to populate fields (so the binary loop
+ // can read each sub-doc's `type` and route attachments to the right doc),
+ // then again after the loop rewrites node text to the attachment reference
+ // so the final field values use those references, not the inline base64.
+ const subDocElements = $record.find('[db-doc=true]').toArray();
+ const docsToStore = subDocElements.map((element: any) => {
+ const docToStore: any = this.enketoTranslationService.reportRecordToJs(getOuterHTML(element));
+ docToStore._id = getId(Xpath.getElementXPath(element));
+ docToStore.reported_date = Date.now();
+ return docToStore;
+ });
doc._id = getId('/*');
if (xmlVersion) {
@@ -481,30 +498,78 @@ export class EnketoService {
}
doc.hidden_fields = this.enketoTranslationService.getHiddenFieldList(record, dbDocTags);
+ // Build a lookup map: sub-doc _couchId -> prepared doc object
+ const subDocById = new Map();
+ docsToStore.forEach(subDoc => subDocById.set(subDoc._id, subDoc));
+
+ // Resolve the owner document for a given XML element by walking
+ // up to the nearest [db-doc="true"] ancestor. Falls back to the
+ // main report doc when the element is not inside a sub-doc.
+ const resolveOwnerDoc = (element) => {
+ let node = element.parentNode;
+ while (node && node !== recordDoc) {
+ if (node._couchId && subDocById.has(node._couchId)) {
+ return subDocById.get(node._couchId);
+ }
+ node = node.parentNode;
+ }
+ return doc;
+ };
+
+ // Route each FileManager file to its owner doc: match the filename against a
+ // [type=file] node, then resolve the owner from its position in the tree.
+ // Inline [type=binary] fields are handled by the loop below.
FileManager
.getCurrentFiles()
- .forEach(file => this.attachmentService.add(doc, `user-file-${file.name}`, file, file.type, false));
+ .forEach(file => {
+ const ownerDoc = resolveOwnerDoc(
+ this.findFileNodeByFilename($record, file.name) ?? $record[0]
+ );
+ this.attachmentService.add(ownerDoc, `${USER_FILE_PREFIX}${file.name}`, file, file.type, false);
+ });
- const attachLegacyFile = (elem, file, type, alreadyEncoded) => {
+ // Attaches an inline-binary blob and returns its bare reference (attachment
+ // name minus USER_FILE_PREFIX), so node text / parsed fields resolve via the
+ // same `USER_FILE_PREFIX + value` rule as file-widget uploads.
+ const attachInlineBinary = (elem, file, type, alreadyEncoded) => {
+ const ownerDoc = resolveOwnerDoc(elem);
const xpath = Xpath.getElementXPath(elem);
- // replace instance root element node name with form internal ID
- const filename = 'user-file' +
- (xpath.startsWith('/' + doc.form) ? xpath : xpath.replace(/^\/[^/]+/, '/' + doc.form));
- this.attachmentService.add(doc, filename, file, type, alreadyEncoded);
+ // Sub-docs carry their own `.form`, so each sub-doc's binaries are
+ // namespaced by it; the main report doc falls back to `doc.form`.
+ const formId = ownerDoc.form || doc.form;
+ // replace instance root element node name with form internal ID, drop
+ // the leading slash -> `//`
+ const reference = (xpath.startsWith('/' + formId) ? xpath : xpath.replace(/^\/[^/]+/, '/' + formId))
+ .slice(1);
+ this.attachmentService.add(ownerDoc, `${USER_FILE_PREFIX}${reference}`, file, type, alreadyEncoded);
+ return reference;
};
$record
.find('[type=binary]')
.each((idx, element) => {
const file = $(element).text();
- if (file) {
- // Attach binary file with legacy-style filename because the actual filename is not stored as the question
- // value in the form model (and so there is currently no way to map the answer in a saved report to the
- // associated file attachment).
- attachLegacyFile(element, file, 'image/png', true);
+ if (!file) {
+ return;
}
+ // Skip values that already look like an attachment reference, so a
+ // re-save can't double-attach the reference string as base64 data.
+ if (this.enketoTranslationService.isAttachmentRef(file)) {
+ return;
+ }
+ const reference = attachInlineBinary(element, file, 'image/png', true);
+ // Rewrite node text to the bare reference so the re-parse below puts the
+ // reference in doc.fields instead of the inline base64.
+ $(element).text(reference);
});
+ // Re-parse sub-docs from the now-rewritten XML and merge in the
+ // reference-name field values.
+ subDocElements.forEach((element: any, idx: number) => {
+ const parsed = this.enketoTranslationService.reportRecordToJs(getOuterHTML(element));
+ Object.assign(docsToStore[idx], parsed);
+ });
+
record = getOuterHTML($record[0]);
// remove old style content attachment
diff --git a/webapp/src/ts/services/form.service.ts b/webapp/src/ts/services/form.service.ts
index f07f0040fd4..7229c7531cb 100644
--- a/webapp/src/ts/services/form.service.ts
+++ b/webapp/src/ts/services/form.service.ts
@@ -404,6 +404,7 @@ export class FormService {
const docs = await this.contactSaveService.save(form, docId, typeFields, xmlVersion);
const preparedDocs = await this.applyTransitions(docs);
+ await this.validateAttachments(preparedDocs.preparedDocs);
const primaryDoc = preparedDocs.preparedDocs.find(doc => doc.type === type);
diff --git a/webapp/src/ts/services/format-data-record.service.ts b/webapp/src/ts/services/format-data-record.service.ts
index 84641f6e660..dbae897ba21 100644
--- a/webapp/src/ts/services/format-data-record.service.ts
+++ b/webapp/src/ts/services/format-data-record.service.ts
@@ -461,6 +461,11 @@ export class FormatDataRecordService {
return undefined;
}
const isImagePath = filePath => doc._attachments[filePath]?.content_type?.startsWith('image/');
+ // A media field's value is its attachment name minus the `user-file-`
+ // prefix, for both file-widget uploads and inline-binary fields. Inline-
+ // binary values carry the form/sub-doc prefix (`//`),
+ // which is what makes sub-doc rendering work without rebuilding it from the
+ // label.
const filePath = 'user-file-' + value;
if (isImagePath(filePath)) {
return filePath;
diff --git a/webapp/tests/karma/js/enketo/file-manager.spec.ts b/webapp/tests/karma/js/enketo/file-manager.spec.ts
index 4755f072e78..d18c9fec707 100644
--- a/webapp/tests/karma/js/enketo/file-manager.spec.ts
+++ b/webapp/tests/karma/js/enketo/file-manager.spec.ts
@@ -28,6 +28,43 @@ describe('file-manager', () => {
expect(fileManager.getMaxSizeReadable()).to.equal(enketoConstants.maxAttachmentSizeReadable);
});
+ describe('getCurrentFiles', () => {
+ let form;
+
+ const addFileInput = (filename, postfix) => {
+ const input = document.createElement('input');
+ input.type = 'file';
+ if (postfix !== undefined) {
+ input.dataset.filenamePostfix = postfix;
+ }
+ const dataTransfer = new DataTransfer();
+ dataTransfer.items.add(new File([ 'content' ], filename, { type: 'image/png' }));
+ input.files = dataTransfer.files;
+ form.appendChild(input);
+ return input;
+ };
+
+ beforeEach(() => {
+ form = document.createElement('form');
+ form.className = 'or';
+ document.body.appendChild(form);
+ });
+
+ afterEach(() => form.remove());
+
+ it('uniquifies the same file uploaded to two inputs via the timestamp postfix', () => {
+ addFileInput('photo.png', '-10_30_01');
+ addFileInput('photo.png', '-10_30_02');
+
+ const files = fileManager.getCurrentFiles();
+
+ expect(files.length).to.equal(2);
+ expect(files[0].name).to.equal('photo-10_30_01.png');
+ expect(files[1].name).to.equal('photo-10_30_02.png');
+ expect(files[0].name).to.not.equal(files[1].name);
+ });
+ });
+
describe('getObjectUrl', () => {
const fakeUrl = 'fake-url';
let originalCHTCore;
diff --git a/webapp/tests/karma/ts/services/contact-save.service.spec.ts b/webapp/tests/karma/ts/services/contact-save.service.spec.ts
index ec045da2be4..5112e5142dd 100644
--- a/webapp/tests/karma/ts/services/contact-save.service.spec.ts
+++ b/webapp/tests/karma/ts/services/contact-save.service.spec.ts
@@ -24,6 +24,8 @@ describe('ContactSave service', () => {
beforeEach(() => {
enketoTranslationService = {
contactRecordToJs: sinon.stub(),
+ // pure regex helper — delegate to the real implementation
+ isAttachmentRef: (value) => new EnketoTranslationService().isAttachmentRef(value),
};
extractLineageService = { extract: sinon.stub() };
@@ -419,9 +421,9 @@ describe('ContactSave service', () => {
'Jane Smith' +
'+254712345679' +
'female' +
- '' +
+ '' +
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' +
- '' +
+ '' +
'' +
'';
@@ -444,7 +446,7 @@ describe('ContactSave service', () => {
expect(
addCall.args[1],
'Should use XPath-based attachment name for binary field'
- ).to.equal('user-file/contact:person:create/person/signature');
+ ).to.equal('user-file-contact:person:create/person/badge');
expect(
addCall.args[2],
'Should pass the base64 content'
@@ -670,7 +672,7 @@ describe('ContactSave service', () => {
'+254712345680' +
'female' +
'BASE64_PHOTO_DATA' +
- 'BASE64_SIGNATURE_DATA' +
+ 'BASE64_BADGE_DATA' +
'' +
'';
@@ -713,19 +715,538 @@ describe('ContactSave service', () => {
expect(
photoCall.args[1],
'Photo binary field XPath-based name'
- ).to.equal('user-file/contact:person:create/person/photo');
+ ).to.equal('user-file-contact:person:create/person/photo');
expect(photoCall.args[2], 'Photo binary field content').to.equal('BASE64_PHOTO_DATA');
expect(photoCall.args[3], 'Binary field content type').to.equal('image/png');
expect(photoCall.args[4], 'Binary field should be pre-encoded').to.be.true;
- const signatureCall = attachmentService.add.getCall(3);
+ const badgeCall = attachmentService.add.getCall(3);
expect(
- signatureCall.args[1],
- 'Signature binary field XPath-based name'
- ).to.equal('user-file/contact:person:create/person/signature');
- expect(signatureCall.args[2], 'Signature binary field content').to.equal('BASE64_SIGNATURE_DATA');
- expect(signatureCall.args[3], 'Binary field content type').to.equal('image/png');
- expect(signatureCall.args[4], 'Binary field should be pre-encoded').to.be.true;
+ badgeCall.args[1],
+ 'Badge binary field XPath-based name'
+ ).to.equal('user-file-contact:person:create/person/badge');
+ expect(badgeCall.args[2], 'Badge binary field content').to.equal('BASE64_BADGE_DATA');
+ expect(badgeCall.args[3], 'Binary field content type').to.equal('image/png');
+ expect(badgeCall.args[4], 'Binary field should be pre-encoded').to.be.true;
+ });
+ });
+
+ describe('attachment routing to sub-contacts', () => {
+ // Each test uses a parent-registration shaped XML with three potential
+ // owners: main section , sibling , and a
+ // repeated under .
+
+ const stubSiblingAndRepeat = () => {
+ // identity extract so prepared docs keep their _id and we can compare
+ extractLineageService.extract.callsFake(contact => contact);
+ };
+
+ it('routes a file uploaded inside a sibling section to that sibling doc', async () => {
+ const xml =
+ '' +
+ '' +
+ 'Kigali HF' +
+ '' +
+ 'Amina' +
+ 'amina.png' +
+ '' +
+ '';
+ const form = { getDataStr: () => xml };
+
+ enketoTranslationService.contactRecordToJs.returns({
+ doc: { _id: 'main1', type: 'family', name: 'Kigali HF', contact: 'NEW' },
+ siblings: { contact: { _id: 'sib1', type: 'person', name: 'Amina', parent: 'PARENT' } },
+ repeats: {},
+ });
+ stubSiblingAndRepeat();
+
+ const aminaFile = new File(['x'], 'amina.png', { type: 'image/png' });
+ sinon.stub(FileManager, 'getCurrentFiles').returns([ aminaFile ]);
+
+ await service.save(form, null, 'family');
+
+ const fileCall = attachmentService.add.getCalls()
+ .find(c => c.args[1] === 'user-file-amina.png');
+ expect(fileCall, 'sibling-routed file attachment should exist').to.not.be.undefined;
+ expect(fileCall.args[0]._id, 'file should land on sibling doc').to.equal('sib1');
+ expect(fileCall.args[2]).to.equal(aminaFile);
+
+ const calls = attachmentService.add.getCalls();
+ expect(
+ calls.some(c => c.args[0]._id === 'main1' && c.args[1] === 'user-file-amina.png'),
+ 'main doc should not receive the sibling file'
+ ).to.be.false;
+ });
+
+ it('routes a file uploaded inside a repeat child to the i-th repeat doc', async () => {
+ const xml =
+ '' +
+ '' +
+ 'Kigali HF' +
+ '' +
+ 'Child AchildA.png' +
+ 'Child BchildB.png' +
+ '' +
+ '';
+ const form = { getDataStr: () => xml };
+
+ enketoTranslationService.contactRecordToJs.returns({
+ doc: { _id: 'main1', type: 'family', name: 'Kigali HF' },
+ siblings: {},
+ repeats: { child_data: [
+ { _id: 'kid1', type: 'person', name: 'Child A', parent: 'PARENT' },
+ { _id: 'kid2', type: 'person', name: 'Child B', parent: 'PARENT' },
+ ] },
+ });
+ stubSiblingAndRepeat();
+
+ const fileA = new File(['a'], 'childA.png', { type: 'image/png' });
+ const fileB = new File(['b'], 'childB.png', { type: 'image/png' });
+ sinon.stub(FileManager, 'getCurrentFiles').returns([ fileA, fileB ]);
+
+ await service.save(form, null, 'family');
+
+ const calls = attachmentService.add.getCalls();
+ const aTarget = calls.find(c => c.args[1] === 'user-file-childA.png');
+ const bTarget = calls.find(c => c.args[1] === 'user-file-childB.png');
+ expect(aTarget?.args[0]._id, 'childA file -> first repeat doc').to.equal('kid1');
+ expect(bTarget?.args[0]._id, 'childB file -> second repeat doc').to.equal('kid2');
+ });
+
+ it('routes mixed uploads to their respective sub-docs', async () => {
+ const xml =
+ '' +
+ '' +
+ 'Kigali HFfacility.jpg' +
+ '' +
+ 'Amina' +
+ 'amina.png' +
+ '' +
+ '' +
+ 'Child AchildA.png' +
+ '' +
+ '';
+ const form = { getDataStr: () => xml };
+
+ enketoTranslationService.contactRecordToJs.returns({
+ doc: { _id: 'main1', type: 'family', name: 'Kigali HF', contact: 'NEW' },
+ siblings: { contact: { _id: 'sib1', type: 'person', name: 'Amina', parent: 'PARENT' } },
+ repeats: { child_data: [
+ { _id: 'kid1', type: 'person', name: 'Child A', parent: 'PARENT' },
+ ] },
+ });
+ stubSiblingAndRepeat();
+
+ const facilityFile = new File(['f'], 'facility.jpg', { type: 'image/jpeg' });
+ const aminaFile = new File(['a'], 'amina.png', { type: 'image/png' });
+ const childFile = new File(['c'], 'childA.png', { type: 'image/png' });
+ sinon.stub(FileManager, 'getCurrentFiles').returns([ facilityFile, aminaFile, childFile ]);
+
+ await service.save(form, null, 'family');
+
+ const calls = attachmentService.add.getCalls();
+ const ownerOf = (name: string) => calls.find(c => c.args[1] === name)?.args[0]._id;
+
+ expect(ownerOf('user-file-facility.jpg'), 'facility upload -> main').to.equal('main1');
+ expect(ownerOf('user-file-amina.png'), 'amina upload -> sibling').to.equal('sib1');
+ expect(ownerOf('user-file-childA.png'), 'child upload -> repeat[0]').to.equal('kid1');
+
+ // each filename appears exactly once across all add calls
+ ['user-file-facility.jpg', 'user-file-amina.png', 'user-file-childA.png']
+ .forEach(name => {
+ const matches = calls.filter(c => c.args[1] === name);
+ expect(matches.length, `${name} should be added exactly once`).to.equal(1);
+ });
+ });
+
+ it('falls back to the main doc when no XML binary node matches the filename', async () => {
+ const xml =
+ '' +
+ '' +
+ 'Kigali HF' +
+ '';
+ const form = { getDataStr: () => xml };
+
+ enketoTranslationService.contactRecordToJs.returns({
+ doc: { _id: 'main1', type: 'family', name: 'Kigali HF' },
+ });
+
+ const orphan = new File(['o'], 'orphan.png', { type: 'image/png' });
+ sinon.stub(FileManager, 'getCurrentFiles').returns([ orphan ]);
+
+ await service.save(form, null, 'family');
+
+ const call = attachmentService.add.getCalls()
+ .find(c => c.args[1] === 'user-file-orphan.png');
+ expect(call?.args[0]._id, 'orphan file should fall back to main').to.equal('main1');
+ });
+
+ it('routes inline binary content from a sibling section to the sibling doc', async () => {
+ const xml =
+ '' +
+ '' +
+ 'Kigali HF' +
+ '' +
+ 'Amina' +
+ 'BASE64_BADGE_DATA' +
+ '' +
+ '';
+ const form = { getDataStr: () => xml };
+
+ enketoTranslationService.contactRecordToJs.returns({
+ doc: { _id: 'main1', type: 'family', name: 'Kigali HF', contact: 'NEW' },
+ siblings: { contact: { _id: 'sib1', type: 'person', name: 'Amina', parent: 'PARENT' } },
+ repeats: {},
+ });
+ stubSiblingAndRepeat();
+ sinon.stub(FileManager, 'getCurrentFiles').returns([]);
+
+ await service.save(form, null, 'family');
+
+ const badgeCall = attachmentService.add.getCalls()
+ .find(c => c.args[1] === 'user-file-contact:family:create/contact/badge');
+ expect(badgeCall, 'sibling-routed inline binary should exist').to.not.be.undefined;
+ expect(badgeCall.args[0]._id, 'inline binary should land on sibling').to.equal('sib1');
+ expect(badgeCall.args[2]).to.equal('BASE64_BADGE_DATA');
+ expect(badgeCall.args[3]).to.equal('image/png');
+ expect(badgeCall.args[4]).to.be.true;
+ expect(badgeCall.args[0].badge).to.equal('contact:family:create/contact/badge');
+ });
+
+ it('routes inline binary content inside a repeat child to the repeat doc', async () => {
+ const xml =
+ '' +
+ '' +
+ 'Kigali HF' +
+ '' +
+ 'Child ABASE64_KID_BADGE' +
+ '' +
+ '';
+ const form = { getDataStr: () => xml };
+
+ enketoTranslationService.contactRecordToJs.returns({
+ doc: { _id: 'main1', type: 'family', name: 'Kigali HF' },
+ siblings: {},
+ repeats: { child_data: [
+ { _id: 'kid1', type: 'person', name: 'Child A', parent: 'PARENT' },
+ ] },
+ });
+ stubSiblingAndRepeat();
+ sinon.stub(FileManager, 'getCurrentFiles').returns([]);
+
+ const result = await service.save(form, null, 'family');
+
+ const badgeCall = attachmentService.add.getCalls().find(c => c.args[2] === 'BASE64_KID_BADGE');
+ expect(badgeCall, 'repeat-child binary attach should exist').to.not.be.undefined;
+ expect(badgeCall.args[0]._id, 'binary lands on the i-th repeat doc').to.equal('kid1');
+
+ const kid = result.preparedDocs.find(d => d._id === 'kid1');
+ expect(kid.badge).to.equal(badgeCall.args[1].replace('user-file-', ''));
+ });
+
+ it('mirrors a nested-group binary value at its dotted field path', async () => {
+ const xml =
+ '' +
+ '' +
+ '' +
+ 'Kigali HF' +
+ 'BASE64_NESTED ' +
+ '' +
+ '';
+ const form = { getDataStr: () => xml };
+
+ enketoTranslationService.contactRecordToJs.returns({
+ doc: { _id: 'main1', type: 'family', name: 'Kigali HF', details: {} },
+ });
+ sinon.stub(FileManager, 'getCurrentFiles').returns([]);
+
+ const result = await service.save(form, null, 'family');
+ const main = result.preparedDocs.find(d => d._id === 'main1');
+
+ const call = attachmentService.add.getCalls().find(c => c.args[2] === 'BASE64_NESTED');
+ expect(call, 'nested binary attach should exist').to.not.be.undefined;
+ expect(main.details.photo).to.equal(call.args[1].replace('user-file-', ''));
+ });
+
+ it('skips an empty binary node (no attachment, field stays empty)', async () => {
+ const xml =
+ '' +
+ '' +
+ 'Kigali HF' +
+ '';
+ const form = { getDataStr: () => xml };
+
+ enketoTranslationService.contactRecordToJs.returns({
+ doc: { _id: 'main1', type: 'family', name: 'Kigali HF', photo: '' },
+ });
+ sinon.stub(FileManager, 'getCurrentFiles').returns([]);
+
+ const result = await service.save(form, null, 'family');
+ const main = result.preparedDocs.find(d => d._id === 'main1');
+
+ expect(attachmentService.add.called, 'no attachment for an empty binary node').to.be.false;
+ expect(main.photo).to.equal('');
+ });
+
+ it('sanitizes field values per-doc', async () => {
+ const xml =
+ '' +
+ '' +
+ 'Kigali HF' +
+ '' +
+ 'Amina' +
+ 'my photo.png' +
+ '' +
+ '';
+ const form = { getDataStr: () => xml };
+
+ enketoTranslationService.contactRecordToJs.returns({
+ doc: { _id: 'main1', type: 'family', name: 'Kigali HF', contact: 'NEW' },
+ siblings: {
+ contact: { _id: 'sib1', type: 'person', name: 'Amina', photo: 'my photo.png', parent: 'PARENT' }
+ },
+ repeats: {},
+ });
+ stubSiblingAndRepeat();
+
+ const aminaFile = new File(['x'], 'my photo.png', { type: 'image/png' });
+ sinon.stub(FileManager, 'getCurrentFiles').returns([ aminaFile ]);
+
+ const result = await service.save(form, null, 'family');
+
+ const sibling = result.preparedDocs.find(d => d._id === 'sib1');
+ const main = result.preparedDocs.find(d => d._id === 'main1');
+ expect(sibling.photo, 'sibling photo field should be sanitized').to.equal('myphoto.png');
+ expect(main.name, 'main field should be untouched').to.equal('Kigali HF');
+ });
+
+ it('main contact re-attaches a binary under the same name (idempotent re-save)', async () => {
+ // On edit the form's instance default re-supplies fresh base64, and the
+ // save recomputes the same xpath-derived name — overwrite-in-place.
+ const xml =
+ '' +
+ '' +
+ '' +
+ 'Kigali HF' +
+ 'FRESH_BASE64' +
+ '' +
+ '';
+ const form = { getDataStr: () => xml };
+
+ enketoTranslationService.contactRecordToJs.returns({
+ doc: { _id: 'main1', type: 'family', name: 'Kigali HF', photo: 'FRESH_BASE64' },
+ });
+
+ // Original returned by the datasource already carries the unified-named attachment.
+ getContact.withArgs(Qualifier.byUuid('main1')).resolves({
+ _id: 'main1',
+ type: 'family',
+ name: 'Kigali HF',
+ _attachments: {
+ 'user-file-contact:family:create/family/photo': { content_type: 'image/png' },
+ },
+ });
+
+ sinon.stub(FileManager, 'getCurrentFiles').returns([]);
+
+ const result = await service.save(form, 'main1', 'family');
+ const main = result.preparedDocs.find(d => d._id === 'main1');
+
+ // Same key the prior save produced — CouchDB overwrite-in-place.
+ const photoCall = attachmentService.add.getCalls()
+ .find(c => c.args[1] === 'user-file-contact:family:create/family/photo');
+ expect(photoCall, 'attach call should exist').to.not.be.undefined;
+ expect(photoCall.args[0]._id).to.equal('main1');
+ expect(photoCall.args[2]).to.equal('FRESH_BASE64');
+ expect(main.photo).to.equal('contact:family:create/family/photo');
+ // The pre-existing attachment is reused, not orphan-removed.
+ expect(
+ attachmentService.remove.calledWith(
+ sinon.match({ _id: 'main1' }),
+ 'user-file-contact:family:create/family/photo',
+ ),
+ 'pre-existing attachment should NOT be removed',
+ ).to.be.false;
+ });
+
+ it('leaves a legacy slash-named binary attachment intact on edit (migration out of scope)', async () => {
+ // Pre-existing legacy attachment under the old slash name
+ // (`user-file/