From c6abc06003b9858e7fcc8d57b4ec4f7601e0f6e5 Mon Sep 17 00:00:00 2001 From: Parvesh M Date: Tue, 27 Jan 2026 12:29:54 +0000 Subject: [PATCH] feat: adds manifest file for the downloaded forms --- designer/server/src/routes/admin/index.js | 165 ++++++++++++------ .../server/src/routes/admin/index.test.js | 44 ++++- 2 files changed, 154 insertions(+), 55 deletions(-) diff --git a/designer/server/src/routes/admin/index.js b/designer/server/src/routes/admin/index.js index 2674fa2ef..8c212adeb 100644 --- a/designer/server/src/routes/admin/index.js +++ b/designer/server/src/routes/admin/index.js @@ -142,7 +142,6 @@ export default [ * @param {FormMetadata} metadata * @param {string} token * @param {ReturnType} archive - * @returns {Promise} true if successful, false if failed */ async function processForm(metadata, token, archive) { // Add metadata first under {id}/metadata.json @@ -157,7 +156,6 @@ async function processForm(metadata, token, archive) { token ) appendFormDefinitionsToArchive(metadata.id, archive, definition) - return true } /** @@ -173,60 +171,16 @@ async function downloadAllFormsAsZip(request, responseToolkit) { try { const startedAt = performance.now() + const { archive, stream } = createArchiveStream() + const response = createZipResponse(responseToolkit, stream) - const stream = new PassThrough() - // Create archive - const archive = Archiver('zip', { zlib: { level: 9 } }) + attachArchiveEventHandlers(archive, stream, request) - // Pipe archive to PassThrough stream - archive.pipe(stream) - - // Set headers - const response = responseToolkit.response(stream) - response.header('Content-Type', 'application/zip') - response.header('Content-Disposition', 'attachment; filename="forms.zip"') - - // Handle archive events - archive.on('warning', (/** @type {Error} */ err) => { - request.logger.warn( - err, - `[downloadAllForms] Archive warning - ${getErrorMessage(err)}` - ) - }) - - archive.on('error', (/** @type {Error} */ err) => { - request.logger.error( - err, - `[downloadAllForms] Archive error - ${getErrorMessage(err)}` - ) - stream.destroy(err) - }) - - // 5 promises at a time - const concurrency = 5 - let totalForms = 0 - let batch = [] - - // Process forms as they're yielded from the generator - for await (const metadata of forms.listAll(token)) { - totalForms++ - batch.push(metadata) - // Process in batches - if (batch.length >= concurrency) { - await Promise.all( - batch.map((formMetadata) => processForm(formMetadata, token, archive)) - ) - batch = [] - } - } + const { totalForms, manifestEntities } = await processAllForms( + token, + archive + ) - // Process remaining forms in batch - if (batch.length > 0) { - await Promise.all( - batch.map((formMetadata) => processForm(formMetadata, token, archive)) - ) - } - // no forms, 404 response if (totalForms === 0) { request.logger.warn('[downloadAllForms] No forms found for download') return responseToolkit @@ -234,6 +188,8 @@ async function downloadAllFormsAsZip(request, responseToolkit) { .code(StatusCodes.NOT_FOUND) } + appendManifestToArchive(archive, totalForms, manifestEntities) + request.logger.info( { totalForms, @@ -245,7 +201,6 @@ async function downloadAllFormsAsZip(request, responseToolkit) { await archive.finalize() const durationMs = performance.now() - startedAt - // Publish audit event await publishFormsBackupRequestedEvent(user, totalForms, durationMs) return response } catch (err) { @@ -262,6 +217,108 @@ async function downloadAllFormsAsZip(request, responseToolkit) { } } +/** + * Create archive and stream + * @returns {{ archive: ReturnType, stream: PassThrough }} + */ +function createArchiveStream() { + const stream = new PassThrough() + const archive = Archiver('zip', { zlib: { level: 9 } }) + archive.pipe(stream) + return { archive, stream } +} + +/** + * Build a response with zip headers + * @param {ResponseToolkit<{ Payload: { action: string; }; }>} responseToolkit + * @param {PassThrough} stream + */ +function createZipResponse(responseToolkit, stream) { + const response = responseToolkit.response(stream) + response.header('Content-Type', 'application/zip') + response.header('Content-Disposition', 'attachment; filename="forms.zip"') + return response +} + +/** + * Attach archive event handlers + * @param {ReturnType} archive + * @param {PassThrough} stream + * @param {Request<{ Payload: { action: string } }>} request + */ +function attachArchiveEventHandlers(archive, stream, request) { + archive.on('warning', (/** @type {Error} */ err) => { + request.logger.warn( + err, + `[downloadAllForms] Archive warning - ${getErrorMessage(err)}` + ) + }) + + archive.on('error', (/** @type {Error} */ err) => { + request.logger.error( + err, + `[downloadAllForms] Archive error - ${getErrorMessage(err)}` + ) + stream.destroy(err) + }) +} + +/** + * Process all forms and append metadata/definitions to archive + * @param {string} token + * @param {ReturnType} archive + * @returns {Promise<{ totalForms: number, manifestEntities: { id: string, title: string, slug: string }[] }>} + */ +async function processAllForms(token, archive) { + const concurrency = 5 + let totalForms = 0 + let batch = [] + /** @type {{ id: string, title: string, slug: string }[]} */ + const manifestEntities = [] + + for await (const metadata of forms.listAll(token)) { + totalForms++ + manifestEntities.push({ + id: metadata.id, + title: metadata.title, + slug: metadata.slug + }) + batch.push(metadata) + if (batch.length >= concurrency) { + await Promise.all( + batch.map((formMetadata) => processForm(formMetadata, token, archive)) + ) + batch = [] + } + } + + if (batch.length > 0) { + await Promise.all( + batch.map((formMetadata) => processForm(formMetadata, token, archive)) + ) + } + + return { totalForms, manifestEntities } +} + +/** + * Append manifest.json to archive + * @param {ReturnType} archive + * @param {number} totalForms + * @param {{ id: string, title: string, slug: string }[]} manifestEntities + */ +function appendManifestToArchive(archive, totalForms, manifestEntities) { + const manifest = { + forms: { + count: totalForms, + entities: manifestEntities + } + } + archive.append(JSON.stringify(manifest, null, 2), { + name: 'manifest.json' + }) +} + /** * Retrieve both live and draft form definitions * @param {string} id - The form metadata ID diff --git a/designer/server/src/routes/admin/index.test.js b/designer/server/src/routes/admin/index.test.js index 2c26ec762..874c78da0 100644 --- a/designer/server/src/routes/admin/index.test.js +++ b/designer/server/src/routes/admin/index.test.js @@ -1,4 +1,5 @@ import Boom from '@hapi/boom' +import archiver from 'archiver' import { StatusCodes } from 'http-status-codes' import { testFormDefinitionWithSinglePage } from '~/src/__stubs__/form-definition.js' @@ -82,6 +83,20 @@ jest.mock('archiver', () => { default: factory } }) + +function getManifestJsonFromArchive() { + const archiverMock = jest.mocked(archiver) + const instance = archiverMock.mock.results[0]?.value + const appendCalls = instance?.append?.mock.calls ?? [] + const manifestCall = appendCalls.find( + (/** @type {{name:string}[]} */ call) => call[1]?.name === 'manifest.json' + ) + if (!manifestCall) { + return undefined + } + return JSON.parse(manifestCall[0]) +} + describe('System admin routes', () => { /** @type {Server} */ let server @@ -319,6 +334,25 @@ describe('System admin routes', () => { expect.any(Number) ) + const manifest = getManifestJsonFromArchive() + expect(manifest).toMatchObject({ + forms: { + count: 2, + entities: expect.arrayContaining([ + expect.objectContaining({ + id: testFormMetadata.id, + title: testFormMetadata.title, + slug: testFormMetadata.slug + }), + expect.objectContaining({ + id: 'form-2', + title: testFormMetadata.title, + slug: 'form-2' + }) + ]) + } + }) + // Verify form definitions were requested expect(forms.getLiveFormDefinition).toHaveBeenCalledTimes(2) expect(forms.getDraftFormDefinition).toHaveBeenCalledTimes(2) @@ -448,6 +482,14 @@ describe('System admin routes', () => { expect.any(Number) ) + const manifest = getManifestJsonFromArchive() + expect(manifest).toMatchObject({ + forms: { + count: 12 + } + }) + expect(manifest.forms.entities).toHaveLength(12) + // Verify all form definitions were fetched expect(forms.getLiveFormDefinition).toHaveBeenCalledTimes(12) expect(forms.getDraftFormDefinition).toHaveBeenCalledTimes(12) @@ -458,5 +500,5 @@ describe('System admin routes', () => { /** * @import { Server } from '@hapi/hapi' * @import Archiver from 'archiver' - * @import {Writable, PassThrough} from 'node:stream' + * @import {PassThrough} from 'node:stream' */