Skip to content

Commit

Permalink
restore soft-deleted submissions
Browse files Browse the repository at this point in the history
  • Loading branch information
ktuite committed Sep 12, 2024
1 parent 0ac8136 commit df04c06
Show file tree
Hide file tree
Showing 4 changed files with 85 additions and 4 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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')
`);

Expand Down
20 changes: 18 additions & 2 deletions lib/model/query/submissions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
};

8 changes: 8 additions & 0 deletions lib/resources/submissions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {

Expand Down
57 changes: 57 additions & 0 deletions test/integration/api/submissions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down

0 comments on commit df04c06

Please sign in to comment.