Skip to content

Commit e07ee02

Browse files
authored
Update Submissions response (getodk#783)
* include currentVersion in submission response * updated API docs * add submitter details for current version if extended data is requested read logical submission from database on submission update * minor changes in API doc
1 parent 51698c1 commit e07ee02

File tree

6 files changed

+111
-37
lines changed

6 files changed

+111
-37
lines changed

docs/api.md

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ Finally, **system information and configuration** is available via a set of spec
3232

3333
Here major and breaking changes to the API are listed by version.
3434

35+
### ODK Central v2023.2
36+
37+
**Changed**:
38+
- The response of `GET`, `POST`, `PUT` and `PATCH` methods of [Submissions](#reference/submissions/listing-all-submissions-on-a-form) endpoint has been updated to include metadata of the `currentVersion` of the Submission.
39+
3540
### ODK Central v2023.1
3641

3742
**Added**:
@@ -2105,6 +2110,8 @@ Like how `Form`s are addressed by their XML `formId`, individual `Submission`s a
21052110

21062111
As of version 1.4, a `deviceId` and `userAgent` will also be returned with each submission. The client device may transmit these extra metadata when the data is submitted. If it does, those fields will be recognized and returned here for reference. Here, only the initial `deviceId` and `userAgent` will be reported. If you wish to see these metadata for any submission edits, including the most recent edit, you will need to [list the versions](/reference/submissions/submission-versions/listing-versions).
21072112

2113+
As of version 2023.2, this API returns `currentVersion` that contains metadata of the most recent version of the Submission.
2114+
21082115
This endpoint supports retrieving extended metadata; provide a header `X-Extended-Metadata: true` to return a `submitter` data object alongside the `submitterId` Actor ID reference.
21092116

21102117
+ Parameters
@@ -2121,6 +2128,11 @@ This endpoint supports retrieving extended metadata; provide a header `X-Extende
21212128

21222129
+ Attributes (Extended Submission)
21232130

2131+
+ Response 301 (text/html)
2132+
Returns 301 with URL of the specific Submission version if `instanceId` of a Submission edit is provided
2133+
2134+
+ Attributes
2135+
21242136
+ Response 403 (application/json)
21252137
+ Attributes (Error 403)
21262138

@@ -2574,12 +2586,12 @@ This endpoint supports retrieving extended metadata; provide a header `X-Extende
25742586
+ Response 200 (application/json)
25752587
This is the standard response, if Extended Metadata is not requested:
25762588

2577-
+ Attributes (array[Submission])
2589+
+ Attributes (array[SubmissionVersion])
25782590

25792591
+ Response 200 (application/json; extended)
25802592
This is the Extended Metadata response, if requested via the appropriate header:
25812593

2582-
+ Attributes (array[Extended Submission])
2594+
+ Attributes (array[Extended SubmissionVersion])
25832595

25842596
+ Response 403 (application/json)
25852597
+ Attributes (Error 403)
@@ -2596,12 +2608,12 @@ This endpoint supports retrieving extended metadata; provide a header `X-Extende
25962608
+ Response 200 (application/json)
25972609
This is the standard response, if Extended Metadata is not requested:
25982610

2599-
+ Attributes (Submission)
2611+
+ Attributes (SubmissionVersion)
26002612

26012613
+ Response 200 (application/json; extended)
26022614
This is the Extended Metadata response, if requested via the appropriate header:
26032615

2604-
+ Attributes (Extended Submission)
2616+
+ Attributes (Extended SubmissionVersion)
26052617

26062618
+ Response 403 (application/json)
26072619
+ Attributes (Error 403)
@@ -4237,17 +4249,29 @@ These are in alphabetic order, with the exception that the `Extended` versions o
42374249

42384250
## Submission (object)
42394251
+ instanceId: `uuid:85cb9aff-005e-4edd-9739-dc9c1a829c44` (string, required) - The `instanceId` of the `Submission`, given by the Submission XML.
4240-
+ instanceName: `village third house` (string, optional) - The `instanceName`, if any, given by the Submission XML in the metadata section.
4241-
+ submitterId: `23` (number, required) - The ID of the `Actor` (`App User`, `User`, or `Public Link`) that submitted this `Submission`.
4242-
+ deviceId: `imei:123456` (string, optional) - The self-identified `deviceId` of the device that collected the data, sent by it upon submission to the server. On overall ("logical") submission requests, the initial submission `deviceId` will be returned here. For specific version listings of a submission, the value associated with the submission of that particular version will be given.
4243-
+ userAgent: `Enketo/3.0.4` (string, optional) - The self-identified `userAgent` of the device that collected the data, sent by it upon submission to the server.
4252+
+ submitterId: `23` (number, required) - The ID of the `Actor` (`App User`, `User`, or `Public Link`) that originally submitted this `Submission`.
4253+
+ deviceId: `imei:123456` (string, optional) - The self-identified `deviceId` of the device that collected the data, sent by it upon submission to the server. The initial submission `deviceId` will be returned here.
4254+
+ userAgent: `Enketo/3.0.4` (string, optional) - The self-identified `userAgent` of the device that collected the data, sent by it upon submission to the server. The initial submission `userAgent` will be returned here.
42444255
+ reviewState: `approved` (Submission Review State, optional) - The current review state of the submission.
42454256
+ createdAt: `2018-01-19T23:58:03.395Z` (string, required) - ISO date format. The time that the server received the Submission.
42464257
+ updatedAt: `2018-03-21T12:45:02.312Z` (string, optional) - ISO date format. `null` when the Submission is first created, then updated when the Submission's XML data or metadata is updated.
4258+
+ currentVersion: (SubmissionVersion) - The current version of the `Submission`.
4259+
4260+
## SubmissionVersion (object)
4261+
+ instanceId: `uuid:85cb9aff-005e-4edd-9739-dc9c1a829c44` (string, required) - The `instanceId` of the `Submission` version, given by the Submission XML.
4262+
+ instanceName: `village third house` (string, optional) - The `instanceName`, if any, given by the Submission XML in the metadata section.
4263+
+ submitterId: `23` (number, required) - The ID of the `Actor` (`App User`, `User`, or `Public Link`) that submitted this `Submission` version.
4264+
+ deviceId: `imei:123456` (string, optional) - The self-identified `deviceId` of the device that submitted the `Submission` version.
4265+
+ userAgent: `Enketo/3.0.4` (string, optional) - The self-identified `userAgent` of the device that submitted the `Submission` version.
4266+
+ createdAt: `2018-01-19T23:58:03.395Z` (string, required) - ISO date format. The time that the server received the `Submission` version.
4267+
+ current: `true` (boolean, required) - Whether the version is current or not.
42474268

42484269
## Extended Submission (Submission)
42494270
+ submitter (Actor, required) - The full details of the `Actor` that submitted this `Submission`.
4250-
+ formVersion: `1.0` (string, optional) - The version of the form the submission was initially created against. Only returned with specific Submission Version requests.
4271+
4272+
## Extended SubmissionVersion (SubmissionVersion)
4273+
+ submitter (Actor, required) - The full details of the `Actor` that submitted this version of the `Submission`.
4274+
+ formVersion: `1.0` (string, optional) - The version of the form the submission version was created against. Only returned with specific Submission Version requests.
42514275

42524276
## Review State Counts (object)
42534277
+ received: `3` (number, required) - The number of submissions receieved with no other review state.

lib/model/frames/submission.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ class Submission extends Frame.define(
2424
'instanceId', readable, 'submitterId', readable,
2525
'deviceId', readable, 'createdAt', readable,
2626
'updatedAt', readable, 'reviewState', readable, writable,
27-
embedded('submitter')
27+
'userAgent', readable,
28+
embedded('submitter'),
29+
embedded('currentVersion')
2830
) {
2931
get def() { return this.aux.def; }
3032
get xml() { return (this.aux.xml != null) ? this.aux.xml.xml : null; }

lib/model/query/submissions.js

Lines changed: 56 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,17 @@ select ins.*, def.id as "submissionDefId" from ins, def;`)
4848
localKey: partial.def.localKey,
4949
encDataAttachmentName: partial.def.encDataAttachmentName,
5050
signature: partial.def.signature,
51-
createdAt: submissionData.createdAt
51+
createdAt: submissionData.createdAt,
52+
userAgent
53+
}),
54+
currentVersion: new Submission.Def({
55+
instanceId: partial.instanceId,
56+
createdAt: submissionData.createdAt,
57+
deviceId,
58+
userAgent,
59+
instanceName: partial.def.instanceName,
60+
current: true,
61+
submitterId: submissionData.submitterId
5262
}),
5363
xml: new Submission.Xml({ xml: partial.xml })
5464
}));
@@ -64,15 +74,24 @@ const createVersion = (partial, deprecated, form, deviceIdIn = null, userAgentIn
6474
const deviceId = blankStringToNull(deviceIdIn);
6575
const userAgent = blankStringToNull(userAgentIn);
6676

77+
const _unjoiner = unjoiner(Submission, Submission.Def.into('currentVersion'));
78+
6779
// we already do transactions but it just feels nice to have the cte do it all at once.
6880
return one(sql`
6981
with logical as (update submissions set "reviewState"='edited', "updatedAt"=clock_timestamp()
70-
where id=${deprecated.submissionId})
71-
, upd as (update submission_defs set current=false where "submissionId"=${deprecated.submissionId})
72-
${_defInsert(deprecated.submissionId, partial, form.def.id, actorId, null, deviceId, userAgent)}`)
73-
// TODO/HACK: lame that we are reconstructing this this way.
74-
.then((saved) => new Submission({ instanceId: partial.instanceId },
75-
{ def: new Submission.Def(saved), xml: new Submission.Xml({ xml: partial.xml }) }));
82+
where id=${deprecated.submissionId}
83+
returning * )
84+
, upd as (update submission_defs set current=false where "submissionId"=${deprecated.submissionId} returning *)
85+
, def as (
86+
${_defInsert(deprecated.submissionId, partial, form.def.id, actorId, null, deviceId, userAgent)}
87+
)
88+
select ${_unjoiner.fields} from (
89+
select logical.*, upd."userAgent" from logical
90+
join upd on logical.id = upd."submissionId" and root
91+
) submissions
92+
join def submission_defs on submissions.id = submission_defs."submissionId"
93+
`)
94+
.then(_unjoiner);
7695
};
7796

7897
createVersion.audit = (partial, deprecated, form) => (log) =>
@@ -134,20 +153,34 @@ order by path asc, value asc`)
134153
////////////////////////////////////////////////////////////////////////////////
135154
// SUBMISSION GETTERS
136155

137-
const _get = extender(Submission)(Actor.into('submitter'))((fields, extend, options, projectId, xmlFormId, draft) => sql`
138-
select ${fields} from submissions
139-
join forms on forms."xmlFormId"=${xmlFormId} and forms.id=submissions."formId"
140-
join projects on projects.id=${projectId} and projects.id=forms."projectId"
141-
${extend|| sql`left outer join actors on actors.id=submissions."submitterId"`}
142-
where ${equals(options.condition)} and draft=${draft} and submissions."deletedAt" is null
143-
order by submissions."createdAt" desc, submissions.id desc
144-
${page(options)}`);
156+
// Helper function to assign submission.currentVersionSubmitter to submission.currentVersion.submitter
157+
// Current there is no way to create such complex object using `extender` and `unjoiner` framework functions
158+
const assignCurrentVersionSubmitter = (x) => x.withAux('currentVersion', x.aux.currentVersion.withAux('submitter', x.aux.currentVersionSubmitter));
159+
160+
const _get = extender(Submission, Submission.Def.into('currentVersion'))(Actor.into('submitter'), Actor.alias('current_version_actors', 'currentVersionSubmitter'))((fields, extend, options, projectId, xmlFormId, draft) => sql`
161+
select ${fields} from
162+
(
163+
select submissions.*, submission_defs."userAgent" from submissions
164+
join submission_defs on submissions.id = submission_defs."submissionId" and root
165+
) submissions
166+
join submission_defs on submissions.id = submission_defs."submissionId" and submission_defs.current
167+
join forms on forms."xmlFormId"=${xmlFormId} and forms.id=submissions."formId"
168+
join projects on projects.id=${projectId} and projects.id=forms."projectId"
169+
${extend|| sql`
170+
left outer join actors on actors.id=submissions."submitterId"
171+
left outer join actors current_version_actors on current_version_actors.id=submission_defs."submitterId"
172+
`}
173+
where ${equals(options.condition)} and draft=${draft} and submissions."deletedAt" is null
174+
order by submissions."createdAt" desc, submissions.id desc
175+
${page(options)}`);
145176

146177
const getByIds = (projectId, xmlFormId, instanceId, draft, options = QueryOptions.none) => ({ maybeOne }) =>
147-
_get(maybeOne, options.withCondition({ instanceId }), projectId, xmlFormId, draft);
178+
_get(maybeOne, options.withCondition({ 'submissions.instanceId': instanceId }), projectId, xmlFormId, draft)
179+
.then(x => x.map(assignCurrentVersionSubmitter));
148180

149181
const getAllForFormByIds = (projectId, xmlFormId, draft, options = QueryOptions.none) => ({ all }) =>
150-
_get(all, options, projectId, xmlFormId, draft);
182+
_get(all, options, projectId, xmlFormId, draft)
183+
.then(map(assignCurrentVersionSubmitter));
151184

152185
const getById = (submissionId) => ({ maybeOne }) =>
153186
maybeOne(sql`select * from submissions where id=${submissionId} and "deletedAt" is null`)
@@ -244,10 +277,12 @@ const _exportUnjoiner = unjoiner(Submission, Submission.Def, Submission.Xml, Sub
244277
// the /only/ place we need to do this in the entire codebase right now. so for now
245278
// we just use the terrible hack.
246279
const { raw } = require('slonik-sql-tag-raw');
247-
const _exportFields = raw(_exportUnjoiner.fields.sql.replace(
248-
'submission_defs."xml" as "submission_defs!xml"',
249-
'(case when submission_defs."localKey" is null then submission_defs.xml end) as "submission_defs!xml"'
250-
));
280+
const _exportFields = raw(_exportUnjoiner.fields.sql
281+
.replace(',submissions."userAgent" as "submissions!userAgent"', '')
282+
.replace(
283+
'submission_defs."xml" as "submission_defs!xml"',
284+
'(case when submission_defs."localKey" is null then submission_defs.xml end) as "submission_defs!xml"'
285+
));
251286

252287
const _export = (formId, draft, keyIds = [], options) => {
253288
const encrypted = keyIds.length !== 0;

lib/resources/submissions.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ module.exports = (service, endpoint) => {
9494
Forms.getBinaryFields(form.def.id),
9595
SubmissionAttachments.getForFormAndInstanceId(form.id, deprecatedId, draft)
9696
]).then(([ saved, binaryFields, deprecatedAtts ]) =>
97-
SubmissionAttachments.create(saved, form, binaryFields, files, deprecatedAtts))))
97+
SubmissionAttachments.create(saved.aux.currentVersion.with({ def: saved.aux.currentVersion }), form, binaryFields, files, deprecatedAtts))))
9898

9999
// ((no deprecatedId given))
100100
: Submissions.getAnyDefByFormAndInstanceId(form.id, partial.instanceId, draft)
@@ -200,7 +200,7 @@ module.exports = (service, endpoint) => {
200200
SubmissionAttachments.getForFormAndInstanceId(form.id, deprecatedId, draft),
201201
])
202202
.then(([ submission, binaryFields, deprecatedAtt ]) =>
203-
SubmissionAttachments.create(submission, form, binaryFields, undefined, deprecatedAtt)
203+
SubmissionAttachments.create(submission.aux.currentVersion.with({ def: submission.aux.currentVersion }), form, binaryFields, undefined, deprecatedAtt)
204204
.then(always(submission))));
205205
})));
206206
};

test/assertions.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,29 +91,32 @@ should.Assertion.add('User', function() {
9191
should.Assertion.add('Submission', function() {
9292
this.params = { operator: 'to be a Submission' };
9393

94-
Object.keys(this.obj).should.containDeep([ 'instanceId', 'createdAt', 'updatedAt', 'submitterId' ]);
94+
Object.keys(this.obj).should.containDeep([ 'instanceId', 'createdAt', 'updatedAt', 'submitterId', 'currentVersion', 'userAgent' ]);
9595
this.obj.instanceId.should.be.a.String();
9696
this.obj.submitterId.should.be.a.Number();
9797
this.obj.createdAt.should.be.an.isoDate();
98+
this.obj.userAgent.should.be.a.String();
99+
this.obj.currentVersion.should.be.a.SubmissionDef();
98100
if (this.obj.updatedAt != null) this.obj.updatedAt.should.be.an.isoDate();
99101
});
100102

101103
// eslint-disable-next-line space-before-function-paren, func-names
102104
should.Assertion.add('ExtendedSubmission', function() {
103105
this.params = { operator: 'to be an extended Submission' };
104106

105-
Object.keys(this.obj).should.containDeep([ 'instanceId', 'createdAt', 'updatedAt', 'submitter' ]);
107+
Object.keys(this.obj).should.containDeep([ 'instanceId', 'createdAt', 'updatedAt', 'submitter', 'currentVersion' ]);
106108
this.obj.instanceId.should.be.a.String();
107109
this.obj.submitter.should.be.an.Actor();
108110
this.obj.createdAt.should.be.an.isoDate();
111+
this.obj.currentVersion.should.be.a.ExtendedSubmissionDef();
109112
if (this.obj.updatedAt != null) this.obj.updatedAt.should.be.an.isoDate();
110113
});
111114

112115
// eslint-disable-next-line space-before-function-paren, func-names
113116
should.Assertion.add('SubmissionDef', function() {
114117
this.params = { operator: 'to be a Submission' };
115118

116-
Object.keys(this.obj).should.containDeep([ 'submitterId', 'createdAt', 'instanceName' ]);
119+
Object.keys(this.obj).should.containDeep([ 'instanceId', 'submitterId', 'createdAt', 'instanceName', 'current', 'userAgent', 'deviceId' ]);
117120
this.obj.submitterId.should.be.a.Number();
118121
this.obj.createdAt.should.be.an.isoDate();
119122
if (this.obj.instanceName != null) this.obj.instanceName.should.be.a.String();

test/integration/api/submissions.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1132,11 +1132,21 @@ describe('api: /forms/:id/submissions', () => {
11321132
asAlice.post('/v1/projects/1/forms/simple/submissions')
11331133
.send(testData.instances.simple.one)
11341134
.set('Content-Type', 'application/xml')
1135+
.set('user-agent', 'node1')
11351136
.expect(200)
11361137
.then(() => asAlice.put('/v1/projects/1/forms/simple/submissions/one')
11371138
.set('Content-Type', 'text/xml')
11381139
.send(withSimpleIds('one', 'two'))
1139-
.expect(200))
1140+
.set('user-agent', 'node2')
1141+
.expect(200)
1142+
.then(({ body }) => {
1143+
body.should.be.a.Submission();
1144+
body.instanceId.should.be.eql('one');
1145+
body.currentVersion.instanceId.should.be.eql('two');
1146+
1147+
body.userAgent.should.be.eql('node1');
1148+
body.currentVersion.userAgent.should.be.eql('node2');
1149+
}))
11401150
.then(() => asAlice.get('/v1/projects/1/forms/simple/submissions/one.xml')
11411151
.expect(200)
11421152
.then(({ text }) => { text.should.equal(withSimpleIds('one', 'two')); })))));

0 commit comments

Comments
 (0)