Skip to content

Commit

Permalink
Add option to deep GET /proposals/{proposalId}
Browse files Browse the repository at this point in the history
When optional query parameter includeFieldsAndValues is set to true on
GET /proposals/{proposalId}, include all proposal versions, associated
values, and associated application form fields in the response.

By returning this almost-fully-deep object tree, it is more convenient
for the caller and more efficient in terms of request, query, and
response counts. The assumption is that the purpose of the PDC is to
see and compare which fields are used for what purpose for proposals.

Issue #101 Implement GET /proposals/{proposalId} endpoint
  • Loading branch information
bickelj committed Jan 23, 2023
1 parent fff8af6 commit abc0291
Show file tree
Hide file tree
Showing 11 changed files with 579 additions and 13 deletions.
303 changes: 302 additions & 1 deletion src/__tests__/proposals.int.test.ts
Expand Up @@ -151,7 +151,16 @@ describe('/proposals', () => {
await agent
.get('/proposals/9001')
.set(dummyApiKey)
.expect(404, { message: 'Not found. Find existing proposals by calling with no parameters.' });
.expect(404, {
name: 'NotFoundError',
message: 'Not found. Find existing proposals by calling with no parameters.',
details: [
{
name: 'NotFoundError',
status: 404,
},
],
});
});

it('returns the one proposal asked for', async () => {
Expand Down Expand Up @@ -199,6 +208,199 @@ describe('/proposals', () => {
);
});

it('returns one proposal with deep fields when includeFieldsAndValues=true', async () => {
// Needs canonical fields,
// opportunity,
// an applicant,
// application form,
// application form fields,
// proposal,
// proposal versions, and
// proposal field values.
await db.query(`
INSERT INTO canonical_fields (
label,
short_code,
data_type,
created_at
)
VALUES
( 'Summary', 'summary', '{ type: "string" }', '2023-01-06T16:22:00+0000' ),
( 'Title', 'title', '{ type: "string" }', '2023-01-06T16:24:00+0000' );
`);
await db.query(`
INSERT INTO opportunities (
title,
created_at
)
VALUES
( '🌎', '2525-01-04T00:00:01Z' )
`);
await db.query(`
INSERT INTO applicants (
external_id,
opted_in,
created_at
)
VALUES
( '🐯', 'true', '2525-01-04T00:00:02Z' ),
( '🐅', 'false', '2525-01-04T00:00:03Z' );
`);
await db.query(`
INSERT INTO application_forms (
opportunity_id,
version,
created_at
)
VALUES
( 1, 1, '2525-01-04T00:00:04Z' )
`);
await db.query(`
INSERT INTO application_form_fields (
application_form_id,
canonical_field_id,
position,
label,
created_at
)
VALUES
( 1, 2, 1, 'Short summary or title', '2525-01-04T00:00:05Z' ),
( 1, 1, 2, 'Long summary or abstract', '2525-01-04T00:00:06Z' );
`);
await db.query(`
INSERT INTO proposals (
applicant_id,
external_id,
opportunity_id,
created_at
)
VALUES
( 2, 'proposal-2525-01-04T00Z', 1, '2525-01-04T00:00:07Z' );
`);
await db.query(`
INSERT INTO proposal_versions (
proposal_id,
application_form_id,
version,
created_at
)
VALUES
( 1, 1, 1, '2525-01-04T00:00:08Z' ),
( 1, 1, 2, '2525-01-04T00:00:09Z' );
`);
await db.query(`
INSERT INTO proposal_field_values (
proposal_version_id,
application_form_field_id,
position,
value,
created_at
)
VALUES
( 1, 1, 1, 'Title for version 1 from 2525-01-04', '2525-01-04T00:00:10Z' ),
( 1, 2, 2, 'Abstract for version 1 from 2525-01-04', '2525-01-04T00:00:11Z' ),
( 2, 1, 1, 'Title for version 2 from 2525-01-04', '2525-01-04T00:00:12Z' ),
( 2, 2, 2, 'Abstract for version 2 from 2525-01-04', '2525-01-04T00:00:13Z' );
`);
await agent
.get('/proposals/1/?includeFieldsAndValues=true')
.set(dummyApiKey)
.expect(
200,
{
id: 1,
applicantId: 2,
opportunityId: 1,
externalId: 'proposal-2525-01-04T00Z',
createdAt: '2525-01-04T00:00:07.000Z',
versions: [
{
id: 2,
proposalId: 1,
applicationFormId: 1,
version: 2,
createdAt: '2525-01-04T00:00:09.000Z',
fieldValues: [
{
id: 3,
proposalVersionId: 2,
applicationFormFieldId: 1,
position: 1,
value: 'Title for version 2 from 2525-01-04',
createdAt: '2525-01-04T00:00:12.000Z',
applicationFormField: {
id: 1,
applicationFormId: 1,
canonicalFieldId: 2,
position: 1,
label: 'Short summary or title',
createdAt: '2525-01-04T00:00:05.000Z',
},
},
{
id: 4,
proposalVersionId: 2,
applicationFormFieldId: 2,
position: 2,
value: 'Abstract for version 2 from 2525-01-04',
createdAt: '2525-01-04T00:00:13.000Z',
applicationFormField: {
id: 2,
applicationFormId: 1,
canonicalFieldId: 1,
position: 2,
label: 'Long summary or abstract',
createdAt: '2525-01-04T00:00:06.000Z',
},
},
],
},
{
id: 1,
proposalId: 1,
applicationFormId: 1,
version: 1,
createdAt: '2525-01-04T00:00:08.000Z',
fieldValues: [
{
id: 1,
proposalVersionId: 1,
applicationFormFieldId: 1,
position: 1,
value: 'Title for version 1 from 2525-01-04',
createdAt: '2525-01-04T00:00:10.000Z',
applicationFormField: {
id: 1,
applicationFormId: 1,
canonicalFieldId: 2,
position: 1,
label: 'Short summary or title',
createdAt: '2525-01-04T00:00:05.000Z',
},
},
{
id: 2,
proposalVersionId: 1,
applicationFormFieldId: 2,
position: 2,
value: 'Abstract for version 1 from 2525-01-04',
createdAt: '2525-01-04T00:00:11.000Z',
applicationFormField: {
id: 2,
applicationFormId: 1,
canonicalFieldId: 1,
position: 2,
label: 'Long summary or abstract',
createdAt: '2525-01-04T00:00:06.000Z',
},
},
],
},
],
},
);
});

it('should error if the database returns an unexpected data structure', async () => {
jest.spyOn(db, 'sql')
.mockImplementationOnce(async () => ({
Expand Down Expand Up @@ -255,6 +457,105 @@ describe('/proposals', () => {
});
});

it('returns 404 when given id is not present and includeFieldsAndValues=true', async () => {
await agent
.get('/proposals/9002?includeFieldsAndValues=true')
.set(dummyApiKey)
.expect(404, {
name: 'NotFoundError',
message: 'Not found. Find existing proposals by calling with no parameters.',
details: [
{
name: 'NotFoundError',
status: 404,
},
],
});
});

it('should error if the database returns an unexpected data structure when includeFieldsAndValues=true', async () => {
jest.spyOn(db, 'sql')
.mockImplementationOnce(async () => ({
rows: [{ foo: 'not a valid result' }],
}) as Result<object>);
const result = await agent
.get('/proposals/9003?includeFieldsAndValues=true')
.set(dummyApiKey)
.expect(500);
expect(result.body).toMatchObject({
name: 'InternalValidationError',
details: expect.any(Array) as unknown[],
});
});

it('returns 500 UnknownError if a generic Error is thrown when selecting and includeFieldsAndValues=true', async () => {
jest.spyOn(db, 'sql')
.mockImplementationOnce(async () => {
throw new Error('This is unexpected');
});
const result = await agent
.get('/proposals/9004?includeFieldsAndValues=true')
.set(dummyApiKey)
.expect(500);
expect(result.body).toMatchObject({
name: 'UnknownError',
details: expect.any(Array) as unknown[],
});
});

it('returns 503 DatabaseError if db error is thrown when includeFieldsAndValues=true', async () => {
await db.query(`
INSERT INTO opportunities (
title,
created_at
)
VALUES
( '🧳', '2525-01-04T00:00:14Z' )
`);
await db.query(`
INSERT INTO applicants (
external_id,
opted_in,
created_at
)
VALUES
( '🐴', 'true', '2525-01-04T00:00:15Z' );
`);
await db.query(`
INSERT INTO proposals (
applicant_id,
external_id,
opportunity_id,
created_at
)
VALUES
( 1, 'proposal-🧳-🐴', 1, '2525-01-04T00:00:16Z' );
`);
jest.spyOn(db, 'sql')
.mockImplementationOnce(async () => {
throw new TinyPgError(
'Something went wrong',
undefined,
{
error: {
code: PostgresErrorCode.INSUFFICIENT_RESOURCES,
},
},
);
});
const result = await agent
.get('/proposals/1?includeFieldsAndValues=true')
.type('application/json')
.set(dummyApiKey)
.expect(503);
expect(result.body).toMatchObject({
name: 'DatabaseError',
details: [{
code: PostgresErrorCode.INSUFFICIENT_RESOURCES,
}],
});
});

describe('POST /', () => {
it('creates exactly one proposal', async () => {
await db.query(`
Expand Down
13 changes: 13 additions & 0 deletions src/database/queries/applicationFormFields/selectByProposalId.sql
@@ -0,0 +1,13 @@
SELECT aff.id AS "id",
aff.application_form_id AS "applicationFormId",
aff.canonical_field_id AS "canonicalFieldId",
aff.position AS "position",
aff.label AS "label",
aff.created_at AS "createdAt"
FROM application_form_fields aff
INNER JOIN proposal_field_values pfv
ON pfv.application_form_field_id = aff.id
INNER JOIN proposal_versions pv
ON pv.id = pfv.proposal_version_id
WHERE pv.proposal_id = :proposalId
ORDER BY pv.version DESC, pfv.position;
11 changes: 11 additions & 0 deletions src/database/queries/proposalFieldValues/selectByProposalId.sql
@@ -0,0 +1,11 @@
SELECT pfv.id AS "id",
pfv.proposal_version_id AS "proposalVersionId",
pfv.application_form_field_id AS "applicationFormFieldId",
pfv.value AS "value",
pfv.position AS "position",
pfv.created_at AS "createdAt"
FROM proposal_field_values pfv
INNER JOIN proposal_versions pv
ON pv.id = pfv.proposal_version_id
WHERE pv.proposal_id = :proposalId
ORDER BY pv.version DESC, pfv.position;
8 changes: 8 additions & 0 deletions src/database/queries/proposalVersions/selectByProposalId.sql
@@ -0,0 +1,8 @@
SELECT pv.id AS "id",
pv.proposal_id AS "proposalId",
pv.application_form_id AS "applicationFormId",
pv.version AS "version",
pv.created_at AS "createdAt"
FROM proposal_versions pv
WHERE pv.proposal_id = :proposalId
ORDER BY pv.version DESC;
10 changes: 5 additions & 5 deletions src/errors/AuthenticationError.ts
@@ -1,8 +1,8 @@
export class AuthenticationError extends Error {
public constructor(
message: string,
) {
super(message);
import { ErrorWithStatus } from './ErrorWithStatus';

export class AuthenticationError extends ErrorWithStatus {
public constructor(message: string) {
super(message, 401);
this.name = this.constructor.name;
}
}
12 changes: 12 additions & 0 deletions src/errors/ErrorWithStatus.ts
@@ -0,0 +1,12 @@
export class ErrorWithStatus extends Error {
public readonly status: number;

public constructor(
message: string,
status: number,
) {
super(message);
this.name = this.constructor.name;
this.status = status;
}
}
8 changes: 8 additions & 0 deletions src/errors/NotFoundError.ts
@@ -0,0 +1,8 @@
import { ErrorWithStatus } from './ErrorWithStatus';

export class NotFoundError extends ErrorWithStatus {
public constructor(message: string) {
super(message, 404);
this.name = this.constructor.name;
}
}

0 comments on commit abc0291

Please sign in to comment.