diff --git a/lib/model/frames/entity.js b/lib/model/frames/entity.js index 7277c4724..9406ad42d 100644 --- a/lib/model/frames/entity.js +++ b/lib/model/frames/entity.js @@ -9,16 +9,18 @@ /* eslint-disable no-multi-spaces */ -const { embedded, Frame, table } = require('../frame'); +const { embedded, Frame, readable, table } = require('../frame'); const { parseSubmissionXml } = require('../../data/entity'); // These Frames don't interact with APIs directly, hence no readable/writable class Entity extends Frame.define( table('entities'), - 'id', 'uuid', + 'id', 'uuid', readable, 'datasetId', - 'createdAt', 'creatorId', - embedded('creator') + 'createdAt', readable, 'creatorId', readable, + 'updatedAt', readable, 'deletedAt', readable, + embedded('creator'), + embedded('currentVersion') ) { get def() { return this.aux.def; } @@ -41,11 +43,24 @@ Entity.Partial = class extends Entity {}; Entity.Def = Frame.define( table('entity_defs', 'def'), - 'id', 'entityId', - 'createdAt', 'current', - 'submissionDefId', - 'label', - 'data' + 'id', 'entityId', + 'createdAt', readable, 'current', readable, + 'submissionDefId', 'label', readable, + 'creatorId', readable, 'userAgent', readable, + 'data', readable, + embedded('creator'), + embedded('source') ); +Entity.Def.Metadata = class extends Entity.Def { + + // we don't want `data` to be selected from database, hence return in forApi() + static get fields() { + return super.fields.filter(f => f !== 'data'); + } +}; + +Entity.Def.Source = Frame.define( + 'type', readable, 'details', readable +); module.exports = { Entity }; diff --git a/lib/model/query/entities.js b/lib/model/query/entities.js index d8bdbf63f..fa6d11d4e 100644 --- a/lib/model/query/entities.js +++ b/lib/model/query/entities.js @@ -8,13 +8,14 @@ // except according to the terms contained in the LICENSE file. const { sql } = require('slonik'); -const { Actor, Entity } = require('../frames'); -const { unjoiner, page } = require('../../util/db'); +const { Actor, Entity, Submission, Form } = require('../frames'); +const { equals, extender, unjoiner, page } = require('../../util/db'); const { map } = require('ramda'); const { construct } = require('../../util/util'); const { QueryOptions } = require('../../util/db'); const { odataFilter } = require('../../data/odata-filter'); const { odataToColumnMap } = require('../../data/entity'); +const { isTrue } = require('../../util/http'); //////////////////////////////////////////////////////////////////////////////// // ENTITY CREATE @@ -119,6 +120,96 @@ const processSubmissionEvent = (event) => (container) => //////////////////////////////////////////////////////////////////////////////// // GETTING ENTITIES +const _get = (includeSource) => { + const frames = [Entity]; + if (includeSource) { + frames.push(Entity.Def.into('currentVersion'), Submission, Submission.Def.into('submissionDef'), Form); + } else { + frames.push(Entity.Def.Metadata.into('currentVersion')); + } + return extender(...frames)(Actor.into('creator'), Actor.alias('current_version_actors', 'currentVersionCreator'))((fields, extend, options, deleted = false) => + sql` + SELECT ${fields} FROM entities + INNER JOIN entity_defs + ON entities.id = entity_defs."entityId" AND entity_defs.current + ${extend||sql` + LEFT JOIN actors ON actors.id=entities."creatorId" + LEFT JOIN actors current_version_actors ON current_version_actors.id=entity_defs."creatorId" + `} + ${!includeSource ? sql`` : sql` + LEFT JOIN submission_defs ON submission_defs.id = entity_defs."submissionDefId" + LEFT JOIN ( + SELECT submissions.*, submission_defs."userAgent" FROM submissions + JOIN submission_defs ON submissions.id = submission_defs."submissionId" AND root + ) submissions ON submissions.id = submission_defs."submissionId" + LEFT JOIN forms ON submissions."formId" = forms.id + `} + where ${equals(options.condition)} and entities."deletedAt" is ${deleted ? sql`not` : sql``} null + order by entity_defs.id, entities."createdAt" desc, entities.id desc +`); +}; + +const getById = (datasetId, uuid, options = QueryOptions.none) => ({ maybeOne }) => + _get(true)(maybeOne, options.withCondition({ datasetId, uuid }), isTrue(options.argData.deleted)) + .then(map((entity) => { + const isSourceSubmission = !!entity.aux.currentVersion.submissionDefId; + + // TODO: revisit this when working on POST /entities + const source = new Entity.Def.Source({ + type: isSourceSubmission ? 'submission' : 'api', + details: isSourceSubmission ? { + xmlFormId: entity.aux.form.xmlFormId, + instanceId: entity.aux.submission.instanceId, + instanceName: entity.aux.submissionDef.instanceName + } : null + }); + + const currentVersion = new Entity.Def(entity.aux.currentVersion, { creator: entity.aux.currentVersionCreator, source }); + + return new Entity(entity, { currentVersion, creator: entity.aux.creator }); + })); + +const getAll = (datasetId, options = QueryOptions.none) => ({ all }) => + _get(false)(all, options.withCondition({ datasetId }), isTrue(options.argData.deleted)) + .then(map((e) => e.withAux('currentVersion', e.aux.currentVersion.withAux('creator', e.aux.currentVersionCreator)))); + +//////////////////////////////////////////////////////////////////////////////// +// GETTING ENTITY DEFS + +const _getDef = extender(Entity.Def, Submission, Submission.Def.into('submissionDef'), Form)(Actor.into('creator'))((fields, extend, options) => sql` + SELECT ${fields} FROM entities + JOIN entity_defs ON entities.id = entity_defs."entityId" + LEFT JOIN submission_defs ON submission_defs.id = entity_defs."submissionDefId" + LEFT JOIN ( + SELECT submissions.*, submission_defs."userAgent" FROM submissions + JOIN submission_defs ON submissions.id = submission_defs."submissionId" AND root + ) submissions ON submissions.id = submission_defs."submissionId" + LEFT JOIN forms ON submissions."formId" = forms.id + ${extend||sql` + LEFT JOIN actors ON actors.id=entity_defs."creatorId" + `} + where ${equals(options.condition)} AND entities."deletedAt" IS NULL + order by entity_defs."createdAt", entity_defs.id +`); + +const getAllDefs = (datasetId, uuid, options = QueryOptions.none) => ({ all }) => + _getDef(all, options.withCondition({ datasetId, uuid })) + .then(map((v) => { + const isSourceSubmission = !!v.submissionDefId; + + // TODO: revisit this when working on POST /entities + const source = new Entity.Def.Source({ + type: isSourceSubmission ? 'submission' : 'api', + details: isSourceSubmission ? { + xmlFormId: v.aux.form.xmlFormId, + instanceId: v.aux.submission.instanceId, + instanceName: v.aux.submissionDef.instanceName + } : null + }); + + return new Entity.Def(v, { creator: v.aux.creator, source }); + })); + // This will check for an entity related to any def of the same submission // as the one specified. Used when trying to reapprove an edited submission. const getDefBySubmissionId = (submissionId) => ({ maybeOne }) => @@ -128,9 +219,6 @@ const getDefBySubmissionId = (submissionId) => ({ maybeOne }) => where s.id = ${submissionId} limit 1`) .then(map(construct(Entity.Def))); -const getByUuid = (uuid) => ({ maybeOne }) => - maybeOne(sql`select * from entities where "uuid" = ${uuid} limit 1`) - .then(map(construct(Entity))); //////////////////////////////////////////////////////////////////////////////// @@ -160,6 +248,7 @@ SELECT count(*) FROM entities module.exports = { createNew, _processSubmissionDef, processSubmissionEvent, streamForExport, - getDefBySubmissionId, getByUuid, - countByDatasetId + getDefBySubmissionId, + countByDatasetId, getById, + getAll, getAllDefs }; diff --git a/lib/resources/entities.js b/lib/resources/entities.js index 870dc7446..847c6168b 100644 --- a/lib/resources/entities.js +++ b/lib/resources/entities.js @@ -7,9 +7,10 @@ // including this file, may be copied, modified, propagated, or distributed // except according to the terms contained in the LICENSE file. -const { getOrNotFound } = require('../util/promise'); +const { getOrNotFound, reject } = require('../util/promise'); const { mergeLeft } = require('ramda'); const { success } = require('../util/http'); +const Problem = require('../util/problem'); const getActor = () => ({ id: 1, @@ -36,84 +37,38 @@ const getEntity = (i) => ({ creatorId: 1 }); -const populateEntities = () => { - const entities = []; - for (let i = 1; i <= 5; i+=1) { - entities.push({ - ...getEntity(i), - currentVersion: getEntityDef() - }); - } - return entities; -}; - module.exports = (service, endpoint) => { - service.get('/projects/:id/datasets/:name/entities', endpoint(async ({ Projects }, { params, queryOptions, query }) => { + service.get('/projects/:projectId/datasets/:name/entities', endpoint(async ({ Datasets, Entities }, { params, auth, queryOptions }) => { - await Projects.getById(params.id, queryOptions).then(getOrNotFound); - const entities = populateEntities(); - - if (queryOptions.extended) { - for (let i = 0; i < 5; i+=1) { - entities[i].currentVersion.creator = getActor(); - entities[i].creator = getActor(); - } - } + const dataset = await Datasets.get(params.projectId, params.name, true).then(getOrNotFound); - if (query.deleted) { - entities[4].deletedAt = new Date(); - return [entities[4]]; - } + await auth.canOrReject('entity.list', dataset); - return entities; + return Entities.getAll(dataset.id, queryOptions); })); - service.get('/projects/:id/datasets/:name/entities/:uuid', endpoint(async ({ Projects }, { params, queryOptions }) => { + service.get('/projects/:projectId/datasets/:name/entities/:uuid', endpoint(async ({ Datasets, Entities }, { params, auth, queryOptions }) => { - await Projects.getById(params.id, queryOptions).then(getOrNotFound); - const entity = getEntity(1); - entity.updatedAt = '2023-01-02T09:00:00.000Z'; - entity.currentVersion = { - ...getEntityDef(), - source: { - type: 'api', - details: null - }, - data: { - firstName: 'Jane', - lastName: 'Roe', - city: 'Toronto' - }, - versionNumber: 2, - label: 'Jane Roe', - createdAt: '2023-01-02T09:00:00.000Z' - }; - return entity; + const dataset = await Datasets.get(params.projectId, params.name, true).then(getOrNotFound); + + await auth.canOrReject('entity.read', dataset); + + return Entities.getById(dataset.id, params.uuid, queryOptions).then(getOrNotFound); })); - service.get('/projects/:id/datasets/:name/entities/:uuid/versions', endpoint(async ({ Projects }, { params, queryOptions }) => { + service.get('/projects/:projectId/datasets/:name/entities/:uuid/versions', endpoint(async ({ Datasets, Entities }, { params, auth, queryOptions }) => { - await Projects.getById(params.id, queryOptions).then(getOrNotFound); + const dataset = await Datasets.get(params.projectId, params.name, true).then(getOrNotFound); - const versions = ['John Doe', 'Jane Doe', 'Richard Roe', 'Janie Doe', 'Baby Roe'].map((name, i) => ({ - ...getEntityDef(), - current: i === 4, - source: { - type: 'api', - details: null - }, - data: { - firstName: name.split(' ')[0], - lastName: name.split(' ')[1], - city: 'Toronto' - }, - versionNumber: i+1, - label: name, - createdAt: new Date() - })); + await auth.canOrReject('entity.read', dataset); + + const defs = await Entities.getAllDefs(dataset.id, params.uuid, queryOptions); + + // it means there's no entity with the provided UUID + if (defs.length === 0) return reject(Problem.user.notFound()); - return versions; + return defs; })); // May not be needed for now diff --git a/test/assertions.js b/test/assertions.js index abfcaec5f..3abde4773 100644 --- a/test/assertions.js +++ b/test/assertions.js @@ -358,7 +358,6 @@ should.Assertion.add('EntityDef', function assertEntityDef() { this.params = { operator: 'to be an Entity Def (version)' }; - this.obj.should.have.property('versionNumber').which.is.a.Number(); // May not be needed this.obj.should.have.property('label').which.is.a.String(); this.obj.should.have.property('current').which.is.a.Boolean(); this.obj.should.have.property('createdAt').which.is.a.isoDate(); @@ -389,14 +388,14 @@ should.Assertion.add('EntitySource', function Source() { this.obj.should.have.property('details'); // details are there only in case of type is submission - if (this.obj.details != null) this.obj.details.should.be.SubmissionDetails(); + if (this.obj.details != null) this.obj.details.should.be.EntitySourceSubmissionDetails(); }); // Entity Source Submission Details -should.Assertion.add('SubmissionDetails', function SubmissionDetails() { - this.params = { operator: 'have Submission details' }; +should.Assertion.add('EntitySourceSubmissionDetails', function SubmissionDetails() { + this.params = { operator: 'have Entity Source Submission details' }; this.obj.should.have.property('xmlFormId').which.is.a.String(); this.obj.should.have.property('instanceId').which.is.a.String(); - this.obj.should.have.property('instanceName').which.is.a.String(); + this.obj.should.have.property('instanceName'); // can be null }); diff --git a/test/integration/api/entities.js b/test/integration/api/entities.js index bc6c425b6..3fe366fff 100644 --- a/test/integration/api/entities.js +++ b/test/integration/api/entities.js @@ -1,24 +1,91 @@ +const appRoot = require('app-root-path'); const { testService } = require('../setup'); +const testData = require('../../data/xml'); +const { sql } = require('slonik'); + +/* eslint-disable import/no-dynamic-require */ +const { exhaust } = require(appRoot + '/lib/worker/worker'); +/* eslint-enable import/no-dynamic-require */ + +const testEntities = (test) => testService(async (service, container) => { + const asAlice = await service.login('alice'); + + await asAlice.post('/v1/projects/1/forms?publish=true') + .send(testData.forms.simpleEntity) + .expect(200); + + const promises = []; + + ['one', 'two'].forEach(async instanceId => { + promises.push(asAlice.post('/v1/projects/1/forms/simpleEntity/submissions') + .send(testData.instances.simpleEntity[instanceId]) + .set('Content-Type', 'application/xml') + .expect(200)); + + promises.push(asAlice.patch(`/v1/projects/1/forms/simpleEntity/submissions/${instanceId}`) + .send({ reviewState: 'approved' }) + .expect(200)); + }); + + await Promise.all(promises); + + await exhaust(container); + + await test(service, container); +}); describe('Entities API', () => { describe('GET /datasets/:name/entities', () => { - it('should return metadata of the entities of the dataset', testService(async (service) => { + + it('should return notfound if the dataset does not exist', testEntities(async (service) => { const asAlice = await service.login('alice'); - await asAlice.get('/v1/projects/1/datasets/People/entities') + await asAlice.get('/v1/projects/1/datasets/nonexistent/entities') + .expect(404); + })); + + it('should reject if the user cannot read', testEntities(async (service) => { + const asChelsea = await service.login('chelsea'); + + await asChelsea.get('/v1/projects/1/datasets/people/entities') + .expect(403); + })); + + it('should happily return given no entities', testService(async (service) => { + const asAlice = await service.login('alice'); + + await asAlice.post('/v1/projects/1/forms?publish=true') + .send(testData.forms.simpleEntity) + .expect(200); + + await asAlice.get('/v1/projects/1/datasets/people/entities') + .expect(200) + .then(({ body }) => { + body.should.eql([]); + }); + })); + + it('should return metadata of the entities of the dataset', testEntities(async (service) => { + const asAlice = await service.login('alice'); + + await asAlice.get('/v1/projects/1/datasets/people/entities') .expect(200) .then(({ body: people }) => { people.forEach(p => { p.should.be.an.Entity(); p.should.have.property('currentVersion').which.is.an.EntityDef(); + p.currentVersion.should.not.have.property('data'); }); }); })); - it('should return metadata of the entities of the dataset - only deleted', testService(async (service) => { + it('should return metadata of the entities of the dataset - only deleted', testEntities(async (service, container) => { const asAlice = await service.login('alice'); - await asAlice.get('/v1/projects/1/datasets/People/entities?deleted=true') + // TODO: use request once it's ready + await container.db.any(sql`UPDATE entities SET "deletedAt" = clock_timestamp() WHERE uuid = '12345678-1234-4123-8234-123456789abc';`); + + await asAlice.get('/v1/projects/1/datasets/people/entities?deleted=true') .expect(200) .then(({ body: people }) => { people.forEach(p => { @@ -30,10 +97,10 @@ describe('Entities API', () => { }); })); - it('should return extended metadata of the entities of the dataset', testService(async (service) => { + it('should return extended metadata of the entities of the dataset', testEntities(async (service) => { const asAlice = await service.login('alice'); - await asAlice.get('/v1/projects/1/datasets/People/entities') + await asAlice.get('/v1/projects/1/datasets/people/entities') .set('X-Extended-Metadata', true) .expect(200) .then(({ body: people }) => { @@ -46,10 +113,32 @@ describe('Entities API', () => { }); describe('GET /datasets/:name/entities/:uuid', () => { - it('should return full entity', testService(async (service) => { + + it('should return notfound if the dataset does not exist', testEntities(async (service) => { + const asAlice = await service.login('alice'); + + await asAlice.get('/v1/projects/1/datasets/nonexistent/entities/123') + .expect(404); + })); + + it('should return notfound if the entity does not exist', testEntities(async (service) => { + const asAlice = await service.login('alice'); + + await asAlice.get('/v1/projects/1/datasets/people/entities/123') + .expect(404); + })); + + it('should reject if the user cannot read', testEntities(async (service) => { + const asChelsea = await service.login('chelsea'); + + await asChelsea.get('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789abc') + .expect(403); + })); + + it('should return full entity', testEntities(async (service) => { const asAlice = await service.login('alice'); - await asAlice.get('/v1/projects/1/datasets/People/entities/00000000-0000-0000-0000-000000000001') + await asAlice.get('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789abc') .expect(200) .then(({ body: person }) => { person.should.be.an.Entity(); @@ -58,21 +147,92 @@ describe('Entities API', () => { person.currentVersion.should.have.property('source').which.is.an.EntitySource(); person.currentVersion.should.have.property('data').which.is.eql({ - firstName: 'Jane', - lastName: 'Roe', - city: 'Toronto' + age: '88', + first_name: 'Alice' }); }); })); - // it should return extended entity + it('should return full extended entity', testEntities(async (service) => { + const asAlice = await service.login('alice'); + + await asAlice.get('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789abc') + .set('X-Extended-Metadata', true) + .expect(200) + .then(({ body: person }) => { + person.should.be.an.ExtendedEntity(); + person.should.have.property('currentVersion').which.is.an.ExtendedEntityDef(); + + person.currentVersion.should.have.property('source').which.is.an.EntitySource(); + + person.currentVersion.should.have.property('data').which.is.eql({ + age: '88', + first_name: 'Alice' + }); + }); + })); + + it('should return full entity even if form+submission has been deleted and purged', testEntities(async (service, container) => { + const asAlice = await service.login('alice'); + + await asAlice.delete('/v1/projects/1/forms/simpleEntity') + .expect(200); + + await container.Forms.purge(true); + + await asAlice.get('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789abc') + .expect(200) + .then(({ body: person }) => { + person.should.be.an.Entity(); + person.should.have.property('currentVersion').which.is.an.EntityDef(); + + // TODO: needs to be revisited after POST/PUT api + person.currentVersion.should.have.property('source').which.is.eql({ + type: 'api', + details: null + }); + + person.currentVersion.should.have.property('data').which.is.eql({ + age: '88', + first_name: 'Alice' + }); + }); + })); }); describe('GET /datasets/:name/entities/:uuid/versions', () => { - it('should return all versions of the Entity', testService(async (service) => { + it('should return notfound if the dataset does not exist', testEntities(async (service) => { + const asAlice = await service.login('alice'); + + await asAlice.get('/v1/projects/1/datasets/nonexistent/entities/123/versions') + .expect(404); + })); + + it('should return notfound if the entity does not exist', testEntities(async (service) => { + const asAlice = await service.login('alice'); + + await asAlice.get('/v1/projects/1/datasets/people/entities/123/versions') + .expect(404); + })); + + it('should reject if the user cannot read', testEntities(async (service) => { + const asChelsea = await service.login('chelsea'); + + await asChelsea.get('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789abc/versions') + .expect(403); + })); + + it('should return all versions of the Entity', testEntities(async (service, container) => { const asAlice = await service.login('alice'); - await asAlice.get('/v1/projects/1/datasets/People/entities/00000000-0000-0000-0000-000000000001/versions') + // Use PUT API once that's ready + const chelsea = await container.db.one(sql`SELECT * FROM users WHERE email = 'chelsea@getodk.org';`); + await container.db.any(sql`UPDATE entity_defs SET current = false;`); + await container.db.any(sql` + INSERT INTO entity_defs ("entityId", "createdAt", "current", "submissionDefId", "data", "creatorId", "userAgent", "label") + SELECT "entityId", clock_timestamp(), TRUE, NULL, "data", ${chelsea.actorId}, 'postman', "label" || ' updated' FROM entity_defs`); + + await asAlice.get('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789abc/versions') .expect(200) .then(({ body: versions }) => { versions.forEach(v => { @@ -83,6 +243,31 @@ describe('Entities API', () => { }); })); + it('should return all versions of the Entity - Extended', testEntities(async (service, container) => { + const asAlice = await service.login('alice'); + + // Use PUT API once that's ready + const chelsea = await container.db.one(sql`SELECT * FROM users WHERE email = 'chelsea@getodk.org';`); + await container.db.any(sql`UPDATE entity_defs SET current = false;`); + await container.db.any(sql` + INSERT INTO entity_defs ("entityId", "createdAt", "current", "submissionDefId", "data", "creatorId", "userAgent", "label") + SELECT "entityId", clock_timestamp(), TRUE, NULL, "data", ${chelsea.actorId}, 'postman', "label" || ' updated' FROM entity_defs`); + + await asAlice.get('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789abc/versions') + .set('X-Extended-Metadata', true) + .expect(200) + .then(({ body: versions }) => { + versions.forEach(v => { + v.should.be.an.ExtendedEntityDef(); + v.should.have.property('source').which.is.an.EntitySource(); + v.should.have.property('data'); + }); + + versions[0].creator.displayName.should.be.eql('Alice'); + versions[1].creator.displayName.should.be.eql('Chelsea'); + }); + })); + }); describe('GET /datasets/:name/entities/:uuid/diffs', () => {