From df04c06884a7791f506024684274f47702af482b Mon Sep 17 00:00:00 2001 From: Kathleen Tuite Date: Wed, 11 Sep 2024 17:46:30 -0700 Subject: [PATCH] restore soft-deleted submissions --- .../20240909-01-add-submission-delete-verb.js | 4 +- lib/model/query/submissions.js | 20 ++++++- lib/resources/submissions.js | 8 +++ test/integration/api/submissions.js | 57 +++++++++++++++++++ 4 files changed, 85 insertions(+), 4 deletions(-) diff --git a/lib/model/migrations/20240909-01-add-submission-delete-verb.js b/lib/model/migrations/20240909-01-add-submission-delete-verb.js index 50938fb39..d9bf1f7bd 100644 --- a/lib/model/migrations/20240909-01-add-submission-delete-verb.js +++ b/lib/model/migrations/20240909-01-add-submission-delete-verb.js @@ -9,13 +9,13 @@ const up = (db) => db.raw(` UPDATE roles -SET verbs = verbs || '["submission.delete"]'::jsonb +SET verbs = verbs || '["submission.delete", "submission.restore"]'::jsonb WHERE system in ('admin', 'manager') `); const down = (db) => db.raw(` UPDATE roles -SET verbs = (verbs - 'submission.delete') +SET verbs = (verbs - 'submission.delete' - 'submission.restore') WHERE system in ('admin', 'manager') `); diff --git a/lib/model/query/submissions.js b/lib/model/query/submissions.js index 0f177606b..2079da80c 100644 --- a/lib/model/query/submissions.js +++ b/lib/model/query/submissions.js @@ -14,7 +14,7 @@ const { Frame, table } = require('../frame'); const { Actor, Form, Submission } = require('../frames'); const { odataFilter, odataOrderBy } = require('../../data/odata-filter'); const { odataToColumnMap, odataSubTableToColumnMap } = require('../../data/submission'); -const { unjoiner, extender, equals, page, updater, QueryOptions, insertMany, markDeleted } = require('../../util/db'); +const { unjoiner, extender, equals, page, updater, QueryOptions, insertMany, markDeleted, markUndeleted } = require('../../util/db'); const { blankStringToNull, construct } = require('../../util/util'); const Problem = require('../../util/problem'); @@ -200,6 +200,17 @@ const getById = (submissionId) => ({ maybeOne }) => maybeOne(sql`select * from submissions where id=${submissionId} and "deletedAt" is null`) .then(map(construct(Submission))); +const getDeleted = (projectId, formId, instanceId) => ({ maybeOne }) => + maybeOne(sql` select submissions.* + from submissions + join forms on submissions."formId" = forms.id + where forms."projectId" = ${projectId} + and submissions."formId" = ${formId} + and submissions."instanceId" = ${instanceId} + and submissions."deletedAt" IS NOT NULL + limit 1`) + .then(map(construct(Submission))); + const joinOnSkiptoken = (skiptoken, formId, draft) => { if (skiptoken == null) return sql``; // In the case of a subtable, we fetch all submissions without pagination: we @@ -393,6 +404,10 @@ const del = (submission) => ({ run }) => del.audit = (submission, form) => (log) => log('submission.delete', submission.with({ acteeId: form.acteeId }), { instanceId: submission.instanceId }); +const restore = (submission) => ({ run }) => + run(markUndeleted(submission)); +restore.audit = (submission, form) => (log) => log('submission.restore', { acteeId: form.acteeId }, { instanceId: submission.instanceId }); + //////////////////////////////////////////////////////////////////////////////// // PURGING SOFT-DELETED SUBMISSIONS @@ -449,11 +464,12 @@ select count(*) from deleted_submissions`); module.exports = { createNew, createVersion, - update, del, purge, clearDraftSubmissions, clearDraftSubmissionsForProject, + update, del, restore, purge, clearDraftSubmissions, clearDraftSubmissionsForProject, setSelectMultipleValues, getSelectMultipleValuesForExport, getByIdsWithDef, getSubAndDefById, getByIds, getAllForFormByIds, getById, countByFormId, verifyVersion, getDefById, getCurrentDefByIds, getAnyDefByFormAndInstanceId, getDefsByFormAndLogicalId, getDefBySubmissionAndInstanceId, getRootForInstanceId, + getDeleted, streamForExport, getForExport }; diff --git a/lib/resources/submissions.js b/lib/resources/submissions.js index 09db84e78..82aa5d235 100644 --- a/lib/resources/submissions.js +++ b/lib/resources/submissions.js @@ -274,6 +274,14 @@ module.exports = (service, endpoint) => { return success(); })); + service.post('/projects/:projectId/forms/:formId/submissions/:instanceId/restore', endpoint(async ({ Forms, Submissions }, { params, auth }) => { + const form = await Forms.getByProjectAndXmlFormId(params.projectId, params.formId, false).then(getOrNotFound); + await auth.canOrReject('submission.restore', form); + const submission = await Submissions.getDeleted(params.projectId, form.id, params.instanceId).then(getOrNotFound); + await Submissions.restore(submission, form); + return success(); + })); + const dataOutputs = (base, draft, getForm) => { diff --git a/test/integration/api/submissions.js b/test/integration/api/submissions.js index e0e700633..d0be1743e 100644 --- a/test/integration/api/submissions.js +++ b/test/integration/api/submissions.js @@ -1371,6 +1371,63 @@ describe('api: /forms/:id/submissions', () => { .expect(404))))); }); + describe('/:instanceId RESTORE', () => { + it('should reject if the submission has not been deleted', testService((service) => + service.login('alice', (asAlice) => + asAlice.post('/v1/projects/1/forms/simple/submissions/one/restore') + .expect(404)))); + + it('should reject if the submission does not exist', testService((service) => + service.login('alice', (asAlice) => + asAlice.post('/v1/projects/1/forms/simple/submissions/nonexistant/restore') + .expect(404)))); + + it('should reject if the user cannot restore', testService(async (service) => { + const asAlice = await service.login('alice'); + const asChelsea = await service.login('chelsea'); + + // Create a submission + await asAlice.post('/v1/projects/1/forms/simple/submissions') + .send(testData.instances.simple.one) + .set('Content-Type', 'application/xml') + .expect(200); + + // Delete the submission + await asAlice.delete('/v1/projects/1/forms/simple/submissions/one') + .expect(200); + + // Chelsea cannot restore + await asChelsea.post('/v1/projects/1/forms/simple/submissions/one/restore') + .expect(403); + })); + + it('should soft-delete the submission and then restore it', testService(async (service) => { + const asAlice = await service.login('alice'); + + // Create a submission + await asAlice.post('/v1/projects/1/forms/simple/submissions') + .send(testData.instances.simple.one) + .set('Content-Type', 'application/xml') + .expect(200); + + // Delete the submission + await asAlice.delete('/v1/projects/1/forms/simple/submissions/one') + .expect(200); + + // Accessing the submission should 404 + await asAlice.get('/v1/projects/1/forms/simple/submissions/one') + .expect(404); + + // Restore the submission + await asAlice.post('/v1/projects/1/forms/simple/submissions/one/restore') + .expect(200); + + // Accessing the submission should 200 + await asAlice.get('/v1/projects/1/forms/simple/submissions/one') + .expect(200); + })); + }); + describe('.csv.zip GET', () => { // NOTE: tests related to decryption of .csv.zip export are located in test/integration/other/encryption.js