Skip to content

Commit dc66ebd

Browse files
authored
Features: Return publishing notes for the Form and Versions API (#1823)
* Features: Return publishing notes for the Form and Versions API * Incorporated PR Feedback * Include publishNotes explicitly instead
1 parent afffcc0 commit dc66ebd

7 files changed

Lines changed: 388 additions & 17 deletions

File tree

docs/api.yaml

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ info:
4141

4242
Here major and breaking changes to the API are listed by version.
4343

44+
## ODK Central v2026.2
45+
46+
**Changed**:
47+
- The [Getting Form Details](/central-api-form-management/#getting-form-details), [Listing Published Form Versions](/central-api-form-management/#listing-published-form-versions) and [Getting Form Version Details](/central-api-form-management/#getting-form-version-details) endpoints now return a `publishNotes` field in extended metadata.
48+
4449
## ODK Central v2026.1
4550

4651
**Added**:
@@ -3356,7 +3361,7 @@ paths:
33563361
updatedAt: 2018-03-21T12:45:02.312Z
33573362
application/json; extended:
33583363
schema:
3359-
$ref: '#/components/schemas/ExtendedForm'
3364+
$ref: '#/components/schemas/ExtendedFormWithPublishNotes'
33603365
example:
33613366
projectId: 1
33623367
xmlFormId: simple
@@ -3384,6 +3389,7 @@ paths:
33843389
deletedAt: 2018-04-18T23:42:11.406Z
33853390
entityRelated: false
33863391
publicLinks: 4
3392+
publishNotes: 'Fixed validation rules for required fields'
33873393
delete:
33883394
tags:
33893395
- Individual Form
@@ -4613,6 +4619,7 @@ paths:
46134619
type: user
46144620
updatedAt: 2018-04-18T23:42:11.406Z
46154621
deletedAt: 2018-04-18T23:42:11.406Z
4622+
publishNotes: Fixed validation rules for required fields
46164623
/v1/projects/{projectId}/forms/{xmlFormId}/versions/{version}:
46174624
get:
46184625
tags:
@@ -4654,6 +4661,9 @@ paths:
46544661
application/json:
46554662
schema:
46564663
$ref: '#/components/schemas/Form'
4664+
application/json; extended:
4665+
schema:
4666+
$ref: '#/components/schemas/ExtendedFormVersion'
46574667
/v1/projects/{projectId}/forms/{xmlFormId}/versions/{version}.xml:
46584668
get:
46594669
tags:
@@ -12609,6 +12619,15 @@ components:
1260912619
type: number
1261012620
example: 4
1261112621
description: The number of Public Links that can submit to the Form. This does not include Public Links that have been revoked.
12622+
ExtendedFormWithPublishNotes:
12623+
allOf:
12624+
- $ref: '#/components/schemas/ExtendedForm'
12625+
- type: object
12626+
properties:
12627+
publishNotes:
12628+
type: string
12629+
example: Fixed validation rules for required fields
12630+
description: The notes provided when the current Form version was published (via the `X-Action-Notes` header). Only returned to users with `form.update` permission.
1261212631
ExtendedFormVersion:
1261312632
allOf:
1261412633
- $ref: '#/components/schemas/Form'
@@ -12620,6 +12639,10 @@ components:
1262012639
excelContentType:
1262112640
type: string
1262212641
description: If the Form was created by uploading an Excel file, this field contains the MIME type of that file.
12642+
publishNotes:
12643+
type: string
12644+
example: Fixed validation rules for required fields
12645+
description: The notes provided when this version was published (via the `X-Action-Notes` header). Only returned to users with `form.update` permission.
1262312646
FormAttachment:
1262412647
type: object
1262512648
required:

lib/model/frames/form.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,8 +160,9 @@ Form.Partial = class extends Form {};
160160
// EXTENDED FORM
161161

162162
Form.Extended = class extends Frame.define(
163+
into('formExtended'),
163164
'submissions', readable, 'lastSubmission', readable,
164-
'excelContentType', readable,
165+
'excelContentType', readable, 'publishNotes',
165166
// counts of submissions in various review states
166167
'receivedCount', 'hasIssuesCount',
167168
'editedCount', 'entityRelated', readable,
@@ -183,7 +184,11 @@ Form.Extended = class extends Frame.define(
183184
}
184185
};
185186

186-
Form.ExtendedVersion = Frame.define('excelContentType', readable);
187+
Form.ExtendedVersion = Frame.define(
188+
into('formVersionExtended'),
189+
'excelContentType', readable,
190+
'publishNotes'
191+
);
187192

188193

189194
////////////////////////////////////////////////////////////////////////////////

lib/model/query/forms.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -607,7 +607,7 @@ const _getVersions = extender(Form, Form.Def)(Form.ExtendedVersion, Option.of(Ac
607607
select ${fields} from forms
608608
join form_defs on ${versionJoinCondition(Form.AllVersions)}
609609
${extend|| sql`
610-
left outer join (select * from audits where action='form.update.publish') as audits
610+
left outer join (select "acteeId", "actorId", details, notes as "publishNotes" from audits where action='form.update.publish') as audits
611611
on forms."acteeId"=audits."acteeId" and audits.details->'newDefId'=to_jsonb(form_defs.id)
612612
left outer join actors on audits."actorId"=actors.id
613613
left outer join (select id, "contentType" as "excelContentType" from blobs) as xls
@@ -667,6 +667,8 @@ ${extend|| sql`
667667
on forms.id=submission_stats."formId"
668668
left outer join (select * from audits where action='form.create') as audits
669669
on forms."acteeId"=audits."acteeId"
670+
left outer join (select "acteeId", details, notes as "publishNotes" from audits where action='form.update.publish') as publish_audits
671+
on forms."acteeId"=publish_audits."acteeId" and publish_audits.details->'newDefId'=to_jsonb(form_defs.id)
670672
left outer join actors on audits."actorId"=actors.id
671673
left outer join (select id, "contentType" as "excelContentType" from blobs) as xls
672674
on form_defs."xlsBlobId"=xls.id

lib/resources/forms.js

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,25 @@ const excelMimeTypes = {
2929
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
3030
};
3131

32-
const canReadForm = (auth, form) => {
32+
const checkActorVerbs = (actorVerbs, requiredVerbs) => {
33+
const actorVerbsSet = new Set(actorVerbs);
34+
if (requiredVerbs.every(v => !actorVerbsSet.has(v))) {
35+
return reject(Problem.user.insufficientRights());
36+
}
37+
};
38+
39+
const canReadForm = async (authOrVerbs, form) => {
40+
const verbs = authOrVerbs instanceof Array
41+
? authOrVerbs
42+
: (await authOrVerbs.verbsOn(form));
3343
if (form.def?.publishedAt == null) {
34-
return auth.canOrReject('form.update', form);
44+
await checkActorVerbs(verbs, ['form.update']);
3545
} else if (form.state === 'closed') {
36-
return auth.canOrReject('form.read', form);
46+
await checkActorVerbs(verbs, ['form.read']);
3747
} else {
38-
return auth.canOrReject(['open_form.read', 'form.read'], form);
48+
await checkActorVerbs(verbs, ['open_form.read', 'form.read']);
3949
}
50+
return form;
4051
};
4152
// Returns QueryOptions to use to limit entity access according to ownerOnly.
4253
const getOwnerOnlyOptions = async (auth, project) => ((await auth.can('entity.list', project))
@@ -276,9 +287,19 @@ module.exports = (service, endpoint, anonymousEndpoint) => {
276287
service.get(`${base}.xls`, getXls('xls'));
277288
service.get(`${base}.xlsx`, getXls('xlsx'));
278289

279-
service.get(`${base}`, endpoint(({ Forms }, { auth, params, queryOptions }) =>
280-
getInstance(Forms, params, false, queryOptions)
281-
.then((form) => canReadForm(auth, form))));
290+
service.get(`${base}`, endpoint(async ({ Forms }, { auth, params, queryOptions }) => {
291+
const form = await getInstance(Forms, params, false, queryOptions);
292+
293+
const verbs = await auth.verbsOn(form);
294+
295+
await canReadForm(verbs, form);
296+
297+
if (verbs.includes('form.update') && queryOptions.extended) {
298+
return { ...form.forApi(), publishNotes: form.aux.formExtended.publishNotes };
299+
}
300+
301+
return form;
302+
}));
282303

283304
// returns form fields, optionally sanitizing names to match odata.
284305
service.get(`${base}/fields`, endpoint(({ Forms }, { params, query, auth }) =>
@@ -365,12 +386,21 @@ module.exports = (service, endpoint, anonymousEndpoint) => {
365386
////////////////////////////////////////
366387
// VERSIONS LISTING
367388

368-
service.get('/projects/:projectId/forms/:xmlFormId/versions', endpoint(({ Forms }, { auth, params, queryOptions }) =>
369-
Forms.getByProjectAndXmlFormId(params.projectId, params.xmlFormId, Form.AnyVersion)
370-
.then(getOrNotFound)
371-
.then((form) => canReadForm(auth, form))
372-
.then((form) => Forms.getVersions(form.id, queryOptions))));
389+
service.get('/projects/:projectId/forms/:xmlFormId/versions', endpoint(async ({ Forms }, { auth, params, queryOptions }) => {
390+
const form = await Forms.getByProjectAndXmlFormId(params.projectId, params.xmlFormId, Form.AnyVersion)
391+
.then(getOrNotFound);
392+
393+
const verbs = await auth.verbsOn(form);
373394

395+
await canReadForm(verbs, form);
396+
397+
const versions = await Forms.getVersions(form.id, queryOptions);
398+
399+
if (verbs.includes('form.update') && queryOptions.extended) {
400+
return versions.map(v => ({ ...v.forApi(), publishNotes: v.aux.formVersionExtended.publishNotes }));
401+
}
402+
return versions;
403+
}));
374404

375405
////////////////////////////////////////
376406
// RESTORE / UNDELETE

test/integration/api/forms/forms.js

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const superagent = require('superagent');
77
const { DateTime } = require('luxon');
88
const { testService } = require('../../setup');
99
const testData = require('../../../data/xml');
10+
const { publishWithNote } = require('../../../util/scenarios');
1011
const { Form } = require(appRoot + '/lib/model/frames');
1112
const { exhaust } = require(appRoot + '/lib/worker/worker');
1213
const { omit } = require(appRoot + '/lib/util/util');
@@ -1092,6 +1093,115 @@ describe('api: /projects/:id/forms (create, read, update)', () => {
10921093
body.enketoOnceId.should.equal('::::abcdefgh');
10931094
}));
10941095
}))));
1096+
1097+
describe('publishNotes', () => {
1098+
it('should return publishNotes with extended metadata', testService(async (service) => {
1099+
const asAlice = await service.login('alice');
1100+
1101+
await publishWithNote(asAlice, '2', 'this is a publishing note');
1102+
1103+
const { body } = await asAlice.get('/v1/projects/1/forms/simple')
1104+
.set('X-Extended-Metadata', true)
1105+
.expect(200);
1106+
1107+
body.publishNotes.should.equal('this is a publishing note');
1108+
}));
1109+
1110+
it('should not return publishNotes for app-user', testService(async (service) => {
1111+
const asAlice = await service.login('alice');
1112+
1113+
await publishWithNote(asAlice, '2', 'this is a publishing note');
1114+
1115+
const appUser = await asAlice.post('/v1/projects/1/app-users')
1116+
.send({ displayName: 'test app user' })
1117+
.expect(200)
1118+
.then(({ body }) => body);
1119+
await asAlice.post(`/v1/projects/1/forms/simple/assignments/app-user/${appUser.id}`)
1120+
.expect(200);
1121+
1122+
const { body } = await service.get(`/v1/key/${appUser.token}/projects/1/forms/simple`)
1123+
.set('X-Extended-Metadata', true)
1124+
.expect(200);
1125+
1126+
should.not.exist(body.publishNotes);
1127+
}));
1128+
1129+
it('should not return publishNotes for project-viewer', testService(async (service) => {
1130+
const asAlice = await service.login('alice');
1131+
1132+
await publishWithNote(asAlice, '2', 'this is a publishing note');
1133+
1134+
const chelsea = await service.login('chelsea');
1135+
const chelseaActorId = await chelsea.get('/v1/users/current')
1136+
.expect(200)
1137+
.then(({ body }) => body.id);
1138+
await asAlice.post(`/v1/projects/1/assignments/viewer/${chelseaActorId}`)
1139+
.expect(200);
1140+
1141+
const { body } = await chelsea.get('/v1/projects/1/forms/simple')
1142+
.set('X-Extended-Metadata', true)
1143+
.expect(200);
1144+
1145+
should.not.exist(body.publishNotes);
1146+
}));
1147+
1148+
it('should not return publishNotes for data-collector', testService(async (service) => {
1149+
const asAlice = await service.login('alice');
1150+
1151+
await publishWithNote(asAlice, '2', 'this is a publishing note');
1152+
1153+
const chelsea = await service.login('chelsea');
1154+
const chelseaActorId = await chelsea.get('/v1/users/current')
1155+
.expect(200)
1156+
.then(({ body }) => body.id);
1157+
await asAlice.post(`/v1/projects/1/assignments/formfill/${chelseaActorId}`)
1158+
.expect(200);
1159+
1160+
const { body } = await chelsea.get('/v1/projects/1/forms/simple')
1161+
.set('X-Extended-Metadata', true)
1162+
.expect(200);
1163+
1164+
should.not.exist(body.publishNotes);
1165+
}));
1166+
1167+
it('should not return publishNotes for data-collector using /forms endpoint', testService(async (service) => {
1168+
const asAlice = await service.login('alice');
1169+
1170+
await publishWithNote(asAlice, '2', 'this is a publishing note');
1171+
1172+
const chelsea = await service.login('chelsea');
1173+
const chelseaActorId = await chelsea.get('/v1/users/current')
1174+
.expect(200)
1175+
.then(({ body }) => body.id);
1176+
await asAlice.post(`/v1/projects/1/assignments/formfill/${chelseaActorId}`)
1177+
.expect(200);
1178+
1179+
const { body } = await chelsea.get('/v1/projects/1/forms')
1180+
.set('X-Extended-Metadata', true)
1181+
.expect(200);
1182+
1183+
body.length.should.be.greaterThan(0);
1184+
body.forEach((form) => should.not.exist(form.publishNotes));
1185+
}));
1186+
1187+
it('should not return publishNotes for data-collector using /projects?forms=true endpoint', testService(async (service) => {
1188+
const asAlice = await service.login('alice');
1189+
1190+
await publishWithNote(asAlice, '2', 'this is a publishing note');
1191+
1192+
const chelsea = await service.login('chelsea');
1193+
const chelseaActorId = await chelsea.get('/v1/users/current')
1194+
.expect(200)
1195+
.then(({ body }) => body.id);
1196+
await asAlice.post(`/v1/projects/1/assignments/formfill/${chelseaActorId}`)
1197+
.expect(200);
1198+
1199+
const { body: projects } = await chelsea.get('/v1/projects?forms=true')
1200+
.expect(200);
1201+
1202+
projects[0].formList.forEach((form) => should.not.exist(form.publishNotes));
1203+
}));
1204+
});
10951205
});
10961206

10971207
////////////////////////////////////////

0 commit comments

Comments
 (0)