diff --git a/docs/api.yaml b/docs/api.yaml index fe1ea72d8..16864ab99 100644 --- a/docs/api.yaml +++ b/docs/api.yaml @@ -40,6 +40,12 @@ info: Here major and breaking changes to the API are listed by version. + ## ODK Central v2024.2 + + **Added**: + + - Extended metadata for Forms includes a new property `publicLinks`, which is the number of Public Links that can submit to the Form. + ## ODK Central v2024.1 **Added**: @@ -2964,7 +2970,7 @@ paths: As of version 1.2, Forms that are unpublished (that only carry a draft and have never been published) will appear with full metadata detail. Previously, certain details like `name` were omitted. You can determine that a Form is unpublished by checking the `publishedAt` value: it will be `null` for unpublished forms. - This endpoint supports retrieving extended metadata; provide a header `X-Extended-Metadata: true` to additionally retrieve the `submissions` count of the number of Submissions that each Form has, the `reviewStates` object of counts of Submissions with specific review states, the `lastSubmission` most recent submission timestamp, as well as the Actor the Form was `createdBy`. + This endpoint supports retrieving extended metadata; provide a header `X-Extended-Metadata: true` to additionally retrieve the `submissions` count of the number of Submissions that each Form has, the `reviewStates` object of counts of Submissions with specific review states, the `lastSubmission` most recent submission timestamp, the Actor the Form was `createdBy`, as well as other metadata. operationId: List all Forms parameters: - name: projectId @@ -3026,6 +3032,7 @@ paths: updatedAt: 2018-04-18T23:42:11.406Z deletedAt: 2018-04-18T23:42:11.406Z entityRelated: false + publicLinks: 4 403: description: Forbidden content: @@ -3281,6 +3288,7 @@ paths: updatedAt: 2018-04-18T23:42:11.406Z deletedAt: 2018-04-18T23:42:11.406Z entityRelated: false + publicLinks: 4 403: description: Forbidden content: @@ -12524,6 +12532,7 @@ components: - submissions - reviewStates - entityRelated + - publicLinks properties: submissions: type: number @@ -12545,6 +12554,10 @@ components: entityRelated: type: boolean description: True only if this Form is related to a Dataset. In v2022.3, this means the Form's Submissions create Entities in a Dataset. In a future version, Submissions will also be able to update existing Entities. + publicLinks: + type: number + example: 4 + description: The number of Public Links that can submit to the Form. This does not include Public Links that have been revoked. ExtendedFormVersion: allOf: - $ref: '#/components/schemas/Form' diff --git a/lib/model/frames/form.js b/lib/model/frames/form.js index 52446558a..38f68fee3 100644 --- a/lib/model/frames/form.js +++ b/lib/model/frames/form.js @@ -148,7 +148,8 @@ Form.Extended = class extends Frame.define( 'excelContentType', readable, // counts of submissions in various review states 'receivedCount', 'hasIssuesCount', - 'editedCount', 'entityRelated', readable + 'editedCount', 'entityRelated', readable, + 'publicLinks', readable ) { forApi() { return { @@ -160,7 +161,8 @@ Form.Extended = class extends Frame.define( edited: this.editedCount || 0 }, lastSubmission: this.lastSubmission, - excelContentType: this.excelContentType + excelContentType: this.excelContentType, + publicLinks: this.publicLinks ?? 0 }; } }; diff --git a/lib/model/query/forms.js b/lib/model/query/forms.js index d72d9750a..e89e4ae57 100644 --- a/lib/model/query/forms.js +++ b/lib/model/query/forms.js @@ -604,7 +604,13 @@ ${extend|| sql` left outer join (select id, "contentType" as "excelContentType" from blobs) as xls on form_defs."xlsBlobId"=xls.id left outer join (select "formDefId", count(1) > 0 "entityRelated" from dataset_form_defs group by "formDefId") as dd - on form_defs.id = dd."formDefId"`} + on form_defs.id = dd."formDefId" + left outer join + (select public_links."formId", count(1)::integer as "publicLinks" + from public_links + inner join sessions on sessions."actorId" = public_links."actorId" + group by public_links."formId") as public_link_counts + on public_link_counts."formId" = forms.id`} ${(actorId == null) ? sql`` : sql` inner join (select id, max(assignment."showDraft") as "showDraft", max(assignment."showNonOpen") as "showNonOpen" from projects diff --git a/test/assertions.js b/test/assertions.js index 398b46c08..16e215f45 100644 --- a/test/assertions.js +++ b/test/assertions.js @@ -208,10 +208,11 @@ should.Assertion.add('ExtendedForm', function() { this.params = { operator: 'to be a ExtendedForm' }; this.obj.should.be.a.Form(); - Object.keys(this.obj).should.containDeep([ 'submissions', 'lastSubmission', 'reviewStates' ]); + Object.keys(this.obj).should.containDeep([ 'submissions', 'lastSubmission', 'reviewStates', 'publicLinks' ]); this.obj.submissions.should.be.a.Number(); Object.keys(this.obj.reviewStates).should.containDeep([ 'received', 'hasIssues', 'edited']); if (this.obj.lastSubmission != null) this.obj.lastSubmission.should.be.an.isoDate(); + this.obj.publicLinks.should.be.a.Number(); }); should.Assertion.add('FormAttachment', function() { diff --git a/test/integration/api/forms/forms.js b/test/integration/api/forms/forms.js index f1c0b90e9..e6e2b952c 100644 --- a/test/integration/api/forms/forms.js +++ b/test/integration/api/forms/forms.js @@ -914,6 +914,38 @@ describe('api: /projects/:id/forms (create, read, update)', () => { body.excelContentType.should.equal('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); }))))); + it('should return count of public links with extended metadata', testService(async (service) => { + const asAlice = await service.login('alice'); + await asAlice.post('/v1/projects/1/forms/simple/public-links') + .send({ displayName: 'link1' }) + .expect(200); + await asAlice.post('/v1/projects/1/forms/simple/public-links') + .send({ displayName: 'link2' }) + .expect(200); + const { body: form } = await asAlice.get('/v1/projects/1/forms/simple') + .set('X-Extended-Metadata', 'true') + .expect(200); + form.publicLinks.should.equal(2); + })); + + it('should exclude deleted and revoked public links', testService(async (service) => { + const asAlice = await service.login('alice'); + const { body: link1 } = await asAlice.post('/v1/projects/1/forms/simple/public-links') + .send({ displayName: 'link1' }) + .expect(200); + const { body: link2 } = await asAlice.post('/v1/projects/1/forms/simple/public-links') + .send({ displayName: 'link2' }) + .expect(200); + await asAlice.delete(`/v1/projects/1/forms/simple/public-links/${link1.id}`) + .expect(200); + await asAlice.delete(`/v1/sessions/${link2.token}`) + .expect(200); + const { body: form } = await asAlice.get('/v1/projects/1/forms/simple') + .set('X-Extended-Metadata', 'true') + .expect(200); + form.publicLinks.should.equal(0); + })); + it('should not return a draftToken', testService((service) => service.login('alice', (asAlice) => asAlice.post('/v1/projects/1/forms/simple/draft')