Skip to content

Commit 592582a

Browse files
authored
Individual submission & entity geodata endpoints (#1622)
Individual submission & entity geodata endpoints
1 parent e41f80d commit 592582a

File tree

9 files changed

+288
-33
lines changed

9 files changed

+288
-33
lines changed

lib/constants.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ const Constants = {
4343

4444
// null means "none of the other statuses"
4545
entityConflictStates: new Set(['soft', 'hard', null]),
46+
47+
// Just the general format, no version validation, and it's fine if the reserved bits are set.
48+
// That's fine for PostgreSQL too.
49+
UUIDRegex: new RegExp(['^', '{8}-', '{4}-', '{4}-', '{4}-', '{12}$'].join('[0-9a-f]'), 'i'),
4650
};
4751

4852
module.exports = { Constants };

lib/http/service.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ module.exports = (container) => {
9898
require('../resources/forms')(service, endpoint, anonymousEndpoint);
9999
require('../resources/users')(service, endpoint, anonymousEndpoint);
100100
require('../resources/sessions')(service, endpoint, anonymousEndpoint);
101+
require('../resources/geo-extracts')(service, endpoint);
101102
require('../resources/submissions')(service, endpoint, anonymousEndpoint);
102103
require('../resources/config')(service, endpoint);
103104
require('../resources/projects')(service, endpoint);
@@ -112,7 +113,6 @@ module.exports = (container) => {
112113
require('../resources/entities')(service, endpoint);
113114
require('../resources/oidc')(service, endpoint, anonymousEndpoint);
114115
require('../resources/user-preferences')(service, endpoint);
115-
require('../resources/geo-extracts')(service, endpoint);
116116

117117
////////////////////////////////////////////////////////////////////////////////
118118
// POSTRESOURCE HANDLERS

lib/model/query/geo-extracts.js

Lines changed: 28 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
// except according to the terms contained in the LICENSE file.
99

1010
const { sql } = require('slonik');
11+
const { arrayHasElements } = require('../../util/util');
1112

1213

1314
const stateFilterToQueryFragments = (field, states, pgType = 'text') => {
@@ -17,37 +18,36 @@ const stateFilterToQueryFragments = (field, states, pgType = 'text') => {
1718
const theFilters = [alsoNullFilter, alsoOtherStatesFilter].filter(el => el !== null);
1819
switch (theFilters.length) {
1920
case 1:
20-
return sql`AND ${theFilters[0]}`;
21+
return sql`${theFilters[0]}`;
2122
case 2:
22-
return sql`AND ${theFilters[0]} OR ${theFilters[1]}`;
23+
return sql`${theFilters[0]} OR ${theFilters[1]}`;
2324
default:
2425
return sql``;
2526
}
2627
};
2728

28-
const getSubmissionFeatureCollectionGeoJson = (formPK, fieldPaths, submitterIds, TSTZRange, reviewStates, deleted, limit) => ({ db }) => {
29-
30-
const formFieldFilter = fieldPaths.length ? sql`AND ffgeo.path = ANY (${sql.array(fieldPaths, 'text')})` : sql`AND ffgeo.is_default`;
31-
const doPathInProperties = fieldPaths.length ? sql`, 'properties', NULL` : sql`, 'properties', json_build_object('fieldpath', path)`;
32-
const doAggregateByPath = fieldPaths.length ? sql`` : sql`, path`;
33-
const submitterFilter = submitterIds.length ? sql`AND sdef."submitterId" = ANY(${sql.array(submitterIds, 'int4')})` : sql``;
34-
const TSTZRangeFilter = TSTZRange ? sql`AND sdef."createdAt" <@ tstzrange(${TSTZRange[0]}, ${TSTZRange[1]}, ${TSTZRange[2]})` : sql``;
35-
const reviewStateFilter = reviewStates.length ? stateFilterToQueryFragments(['sub', 'reviewState'], reviewStates) : sql``;
36-
const deletedFilter = deleted ? sql`IS NOT NULL` : sql`IS NULL`;
29+
const getSubmissionFeatureCollectionGeoJson = (formPK, IDs, fieldPaths, submitterIds, TSTZRange, reviewStates, deleted, limit, onlyCurrent=true, assumeRootSubmissionId=true) => ({ db }) => {
30+
const idFilterPredicate = sql`AND ${sql.identifier([onlyCurrent ? 'sub' : 'sdef', 'instanceId'])} = ANY(${sql.array(IDs, 'text')})`;
31+
const submissionIdFilter = (onlyCurrent && arrayHasElements(IDs)) ? idFilterPredicate : sql``;
32+
const submissionDefIdFilter = (!onlyCurrent && arrayHasElements(IDs)) ? idFilterPredicate : sql``;
33+
const onlyCurrentFilter = onlyCurrent ? sql`AND sdef.current` : sql``;
34+
const formFieldFilter = arrayHasElements(fieldPaths) ? (fieldPaths.includes('all') ? sql`` : sql`AND ffgeo.path = ANY (${sql.array(fieldPaths, 'text')})`) : sql`AND ffgeo.is_default`;
35+
const doPathInProperties = arrayHasElements(fieldPaths) ? sql`, 'properties', NULL` : sql`, 'properties', json_build_object('fieldpath', path)`;
36+
const doAggregateByPath = arrayHasElements(fieldPaths) ? sql`` : sql`, path`;
37+
const submitterFilter = arrayHasElements(submitterIds) ? sql`AND sdef."submitterId" = ANY(${sql.array(submitterIds, 'int4')})` : sql``;
38+
const TSTZRangeFilter = arrayHasElements(TSTZRange) ? sql`AND sdef."createdAt" <@ tstzrange(${TSTZRange[0]}, ${TSTZRange[1]}, ${TSTZRange[2]})` : sql``;
39+
const reviewStateFilter = arrayHasElements(reviewStates) ? sql`AND ${stateFilterToQueryFragments(['sub', 'reviewState'], reviewStates)}` : sql``;
40+
const deletedFilter = deleted ? sql`AND sub."deletedAt" IS NOT NULL` : sql`AND sub."deletedAt" IS NULL`;
3741
const queryLimit = limit ? sql`LIMIT ${limit}` : sql``;
38-
42+
// data coming from revisions are in some cases expected to take on the instanceId of the root of the edit-lineage. And in some other cases, not.
43+
const reportedInstanceIdPicker = assumeRootSubmissionId ? sql`CASE WHEN root THEN sdef."instanceId" ELSE sub."instanceId" END as "instanceId"` : sql`sdef."instanceId"`;
3944
return db.oneFirst(sql`
4045
-- all targeted submission fields, with info on whether the geojson-value is cached
4146
WITH geo_needed AS (
4247
SELECT
4348
sxg.submission_def_id IS NOT NULL as is_cached,
4449
sdef.id as submission_def_id,
45-
CASE
46-
WHEN root
47-
THEN sdef."instanceId"
48-
ELSE
49-
sub."instanceId" -- revisions take on the instanceId of the root of the edit-lineage
50-
END as "instanceId",
50+
${reportedInstanceIdPicker},
5151
ffgeo.type,
5252
ffgeo.fieldhash,
5353
ffgeo.repeatgroup_cardinality,
@@ -60,15 +60,15 @@ const getSubmissionFeatureCollectionGeoJson = (formPK, fieldPaths, submitterIds,
6060
fdef."formId" = ${formPK}
6161
AND
6262
sdef."formDefId" = fdef.id
63-
AND
64-
sdef.current
63+
${submissionDefIdFilter}
64+
${onlyCurrentFilter}
6565
${submitterFilter}
6666
${TSTZRangeFilter}
6767
)
6868
INNER JOIN submissions sub ON (
6969
sdef."submissionId" = sub.id
70-
AND
71-
sub."deletedAt" ${deletedFilter}
70+
${submissionIdFilter}
71+
${deletedFilter}
7272
${reviewStateFilter}
7373
)
7474
INNER JOIN form_field_geo ffgeo ON (
@@ -183,11 +183,12 @@ const getSubmissionFeatureCollectionGeoJson = (formPK, fieldPaths, submitterIds,
183183
};
184184

185185

186-
const getEntityFeatureCollectionGeoJson = (datasetPK, creatorIds, TSTZRange, conflictStates, deleted, limit) => ({ db }) => {
186+
const getEntityFeatureCollectionGeoJson = (datasetPK, IDs, creatorIds, TSTZRange, conflictStates, deleted, limit) => ({ db }) => {
187187

188-
const creatorFilter = creatorIds.length ? sql`AND e."creatorId" = ANY(${sql.array(creatorIds, 'int4')})` : sql``;
189-
const TSTZRangeFilter = TSTZRange ? sql`AND e."createdAt" <@ tstzrange(${TSTZRange[0]}, ${TSTZRange[1]}, ${TSTZRange[2]})` : sql``;
190-
const conflictStatusFilter = conflictStates.length ? stateFilterToQueryFragments(['e', 'conflict'], conflictStates, 'conflictType') : sql``;
188+
const idFilter = arrayHasElements(IDs) ? sql`AND e.uuid = ANY(${sql.array(IDs, 'text')})` : sql``; // TODO: this should be a uuid[] array once/if the entities."uuid" column is converted from varchar(255) to uuid
189+
const creatorFilter = arrayHasElements(creatorIds) ? sql`AND e."creatorId" = ANY(${sql.array(creatorIds, 'int4')})` : sql``;
190+
const TSTZRangeFilter = arrayHasElements(TSTZRange) ? sql`AND e."createdAt" <@ tstzrange(${TSTZRange[0]}, ${TSTZRange[1]}, ${TSTZRange[2]})` : sql``;
191+
const conflictStatusFilter = arrayHasElements(conflictStates) ? sql`AND ${stateFilterToQueryFragments(['e', 'conflict'], conflictStates, 'conflictType')}` : sql``;
191192
const deletedFilter = deleted ? sql`IS NOT NULL` : sql`IS NULL`;
192193
const queryLimit = limit ? sql`LIMIT ${limit}` : sql``;
193194

@@ -208,6 +209,7 @@ const getEntityFeatureCollectionGeoJson = (datasetPK, creatorIds, TSTZRange, con
208209
e."datasetId" = ds.id
209210
AND
210211
e."deletedAt" ${deletedFilter}
212+
${idFilter}
211213
${creatorFilter}
212214
${TSTZRangeFilter}
213215
${conflictStatusFilter}

lib/model/query/submissions.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,17 @@ join submission_defs on submission_defs."instanceId"=${instanceId}
385385
where "formId"=${formId} and draft=${draft} and submissions."deletedAt" is null`)
386386
.then(map((row) => row.instanceId));
387387

388+
const defExists = (formId, submissionInstanceId, versionInstanceId = null) => (db) => db.oneFirst(
389+
sql`SELECT EXISTS(
390+
SELECT 1
391+
FROM submissions sub
392+
INNER JOIN submission_defs def ON (
393+
sub."deletedAt" IS NULL
394+
AND
395+
(sub."formId", sub.id, sub."instanceId", def."instanceId") = (${formId}, def."submissionId", ${submissionInstanceId}, ${(versionInstanceId || submissionInstanceId)})
396+
)
397+
)`
398+
);
388399

389400
////////////////////////////////////////////////////////////////////////////////
390401
// EXPORT
@@ -532,7 +543,7 @@ module.exports = {
532543
setSelectMultipleValues, getSelectMultipleValuesForExport,
533544
getByIdsWithDef, getSubAndDefById,
534545
getByIds, getAllForFormByIds, getById, countByFormId, verifyVersion,
535-
getDefById, getCurrentDefByIds, getCurrentDefColByIds, getCurrentDefColsByIds, getAnyDefByFormAndInstanceId, getDefsByFormAndLogicalId, getDefBySubmissionAndInstanceId, getRootForInstanceId,
546+
getDefById, getCurrentDefByIds, getCurrentDefColByIds, getCurrentDefColsByIds, getAnyDefByFormAndInstanceId, getDefsByFormAndLogicalId, getDefBySubmissionAndInstanceId, getRootForInstanceId, defExists,
536547
getDeleted,
537548
streamForExport, getForExport
538549
};

lib/resources/entities.js

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@
88
// except according to the terms contained in the LICENSE file.
99

1010
const { getOrNotFound, reject } = require('../util/promise');
11-
const { isTrue, success } = require('../util/http');
11+
const { isTrue, success, json } = require('../util/http');
1212
const { Entity } = require('../model/frames');
1313
const Problem = require('../util/problem');
1414
const { diffEntityData, extractBulkSource, getWithConflictDetails } = require('../data/entity');
1515
const { QueryOptions } = require('../util/db');
16+
const { Constants } = require('../constants');
1617

1718
module.exports = (service, endpoint) => {
1819

@@ -87,6 +88,29 @@ module.exports = (service, endpoint) => {
8788

8889
}));
8990

91+
92+
service.get('/projects/:projectId/datasets/:name/entities/:uuid/geojson', endpoint.plain(async ({ Datasets, Entities, GeoExtracts }, { params, auth }) => {
93+
94+
// Saves a query and gives informative feedback if we do this straight away
95+
if (!Constants.UUIDRegex.test(params.uuid)) {
96+
throw Problem.user.unexpectedValue({
97+
field: 'URL UUID path component',
98+
value: params.uuid,
99+
reason: `is not interpretable as a UUID: ${params.uuid}`,
100+
});
101+
}
102+
103+
const dataset = await Datasets.get(params.projectId, params.name, true).then(getOrNotFound);
104+
await auth.canOrReject('entity.read', dataset);
105+
106+
// We need to get the entity itself to see if it exists (and if not, return a 404).
107+
// That's because when getting the geodata, we can't distinguish between the entity not having any geodata (or having invalid geodata)
108+
// and the entity itself not existing.
109+
await Entities.getById(dataset.id, params.uuid).then(getOrNotFound);
110+
return GeoExtracts.getEntityFeatureCollectionGeoJson(dataset.id, [params.uuid]).then(json);
111+
}));
112+
113+
90114
// Create a single entity or bulk create multiple entities.
91115
// In either case, this appends new entities to a dataset.
92116
service.post('/projects/:id/datasets/:name/entities', endpoint(async ({ Datasets, Entities }, { auth, body, params, userAgent }) => {

lib/resources/geo-extracts.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,32 @@ const { getOrNotFound } = require('../util/promise');
1111
const { Form } = require('../model/frames');
1212
const { Sanitize } = require('../util/param-sanitize');
1313
const { isTrue, json } = require('../util/http');
14+
const Problem = require('../util/problem');
1415

1516

17+
const getSingleGeo = async ({ Forms, Submissions, GeoExtracts }, { params, auth, query }, rootId, versionId) => {
18+
const form = await Forms.getByProjectAndXmlFormId(params.projectId, params.xmlFormId, Form.WithoutDef, Form.WithoutXml)
19+
.then(getOrNotFound)
20+
.then((foundForm) => auth.canOrReject('submission.read', foundForm));
21+
// we need to distinguish between submission-does-not-exist and submission-exists-but-has-no-(valid-)geodata, hence this probe.
22+
if (!await Submissions.defExists(form.id, rootId, versionId)) throw Problem.user.notFound();
23+
return GeoExtracts.getSubmissionFeatureCollectionGeoJson(form.id, [versionId || rootId], Sanitize.queryParamToArray(query.fieldpath), null, null, null, false, null, !versionId, false)
24+
.then(json);
25+
};
26+
1627
module.exports = (service, endpoint) => {
1728

29+
// Single-submission endpoints
30+
service.get(`/projects/:projectId/forms/:xmlFormId/submissions/:instanceId.geojson`, endpoint.plain(async (container, context) =>
31+
getSingleGeo(container, context, context.params.instanceId, null)
32+
));
33+
34+
service.get(`/projects/:projectId/forms/:xmlFormId/submissions/:rootId/versions/:instanceId.geojson`, endpoint.plain(async (container, context) =>
35+
getSingleGeo(container, context, context.params.rootId, context.params.instanceId)
36+
));
37+
38+
39+
// Bulk endpoints
1840
service.get('/projects/:projectId/forms/:xmlFormId/submissions.geojson', endpoint.plain(async ({ Forms, GeoExtracts }, { auth, query, params }) => {
1941

2042
const form = await Forms.getByProjectAndXmlFormId(params.projectId, params.xmlFormId, Form.WithoutDef, Form.WithoutXml)
@@ -23,6 +45,7 @@ module.exports = (service, endpoint) => {
2345

2446
return GeoExtracts.getSubmissionFeatureCollectionGeoJson(
2547
form.id,
48+
Sanitize.queryParamToArray(query.submissionID),
2649
Sanitize.queryParamToArray(query.fieldpath),
2750
Sanitize.queryParamToIntArray(query.submitterId, 'submitterId'),
2851
Sanitize.getTSTZRangeFromQueryParams(query),
@@ -40,6 +63,7 @@ module.exports = (service, endpoint) => {
4063

4164
return GeoExtracts.getEntityFeatureCollectionGeoJson(
4265
foundDataset.id,
66+
Sanitize.queryParamToUuidArray(query.entityUUID, 'entityUUID'),
4367
Sanitize.queryParamToIntArray(query.creatorId, 'creatorId'),
4468
Sanitize.getTSTZRangeFromQueryParams(query),
4569
Sanitize.getEntityConflictStates(query.conflict, 'conflict'),

lib/util/param-sanitize.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,22 @@ class Sanitize {
5757
}
5858

5959

60+
static queryParamToUuidArray(queryParam, queryParamName) {
61+
return this.queryParamToArray(queryParam).map(el => {
62+
const trimmed = el.trim();
63+
if (!Constants.UUIDRegex.test(trimmed)) {
64+
throw Problem.user.unexpectedValue({
65+
field: `query parameter '${queryParamName}'`,
66+
value: trimmed,
67+
reason: `is not interpretable as a UUID: ${el}`,
68+
});
69+
} else {
70+
return trimmed;
71+
}
72+
});
73+
}
74+
75+
6076
static queryParamToIntArray(queryParam, queryParamName) {
6177
return this.queryParamToArray(queryParam).map(el => {
6278
const inted = parseInt(el, 10);

lib/util/util.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ const truncateString = length => str => (str ? str.substring(0, length) : str);
3636
const sanitizeOdataIdentifier = (name) =>
3737
name.replace(/^([^a-z_])/i, '_$1').replace(/([^a-z0-9_]+)/gi, '_');
3838

39+
const arrayHasElements = (fnarg) => (Array.isArray(fnarg) && fnarg.length);
40+
3941
////////////////////////////////////////
4042
// OBJECTS
4143

@@ -88,7 +90,7 @@ const attachmentToDatasetName = (attachmentName) => attachmentName.replace(/\.cs
8890

8991
module.exports = {
9092
noop, noargs, applyPipe,
91-
isBlank, isPresent, blankStringToNull, truncateString, sanitizeOdataIdentifier,
93+
isBlank, isPresent, blankStringToNull, truncateString, sanitizeOdataIdentifier, arrayHasElements,
9294
printPairs, omit, pickAll,
9395
base64ToUtf8, utf8ToBase64,
9496
construct, attachmentToDatasetName

0 commit comments

Comments
 (0)