Skip to content

Commit c9e413b

Browse files
committed
Add option to deep GET /proposals/{proposalId}
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
1 parent fff8af6 commit c9e413b

File tree

12 files changed

+964
-13
lines changed

12 files changed

+964
-13
lines changed

src/__tests__/proposals.int.test.ts

Lines changed: 302 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,16 @@ describe('/proposals', () => {
151151
await agent
152152
.get('/proposals/9001')
153153
.set(dummyApiKey)
154-
.expect(404, { message: 'Not found. Find existing proposals by calling with no parameters.' });
154+
.expect(404, {
155+
name: 'NotFoundError',
156+
message: 'Not found. Find existing proposals by calling with no parameters.',
157+
details: [
158+
{
159+
name: 'NotFoundError',
160+
status: 404,
161+
},
162+
],
163+
});
155164
});
156165

157166
it('returns the one proposal asked for', async () => {
@@ -199,6 +208,199 @@ describe('/proposals', () => {
199208
);
200209
});
201210

211+
it('returns one proposal with deep fields when includeFieldsAndValues=true', async () => {
212+
// Needs canonical fields,
213+
// opportunity,
214+
// an applicant,
215+
// application form,
216+
// application form fields,
217+
// proposal,
218+
// proposal versions, and
219+
// proposal field values.
220+
await db.query(`
221+
INSERT INTO canonical_fields (
222+
label,
223+
short_code,
224+
data_type,
225+
created_at
226+
)
227+
VALUES
228+
( 'Summary', 'summary', '{ type: "string" }', '2023-01-06T16:22:00+0000' ),
229+
( 'Title', 'title', '{ type: "string" }', '2023-01-06T16:24:00+0000' );
230+
`);
231+
await db.query(`
232+
INSERT INTO opportunities (
233+
title,
234+
created_at
235+
)
236+
VALUES
237+
( '🌎', '2525-01-04T00:00:01Z' )
238+
`);
239+
await db.query(`
240+
INSERT INTO applicants (
241+
external_id,
242+
opted_in,
243+
created_at
244+
)
245+
VALUES
246+
( '🐯', 'true', '2525-01-04T00:00:02Z' ),
247+
( '🐅', 'false', '2525-01-04T00:00:03Z' );
248+
`);
249+
await db.query(`
250+
INSERT INTO application_forms (
251+
opportunity_id,
252+
version,
253+
created_at
254+
)
255+
VALUES
256+
( 1, 1, '2525-01-04T00:00:04Z' )
257+
`);
258+
await db.query(`
259+
INSERT INTO application_form_fields (
260+
application_form_id,
261+
canonical_field_id,
262+
position,
263+
label,
264+
created_at
265+
)
266+
VALUES
267+
( 1, 2, 1, 'Short summary or title', '2525-01-04T00:00:05Z' ),
268+
( 1, 1, 2, 'Long summary or abstract', '2525-01-04T00:00:06Z' );
269+
`);
270+
await db.query(`
271+
INSERT INTO proposals (
272+
applicant_id,
273+
external_id,
274+
opportunity_id,
275+
created_at
276+
)
277+
VALUES
278+
( 2, 'proposal-2525-01-04T00Z', 1, '2525-01-04T00:00:07Z' );
279+
`);
280+
await db.query(`
281+
INSERT INTO proposal_versions (
282+
proposal_id,
283+
application_form_id,
284+
version,
285+
created_at
286+
)
287+
VALUES
288+
( 1, 1, 1, '2525-01-04T00:00:08Z' ),
289+
( 1, 1, 2, '2525-01-04T00:00:09Z' );
290+
`);
291+
await db.query(`
292+
INSERT INTO proposal_field_values (
293+
proposal_version_id,
294+
application_form_field_id,
295+
position,
296+
value,
297+
created_at
298+
)
299+
VALUES
300+
( 1, 1, 1, 'Title for version 1 from 2525-01-04', '2525-01-04T00:00:10Z' ),
301+
( 1, 2, 2, 'Abstract for version 1 from 2525-01-04', '2525-01-04T00:00:11Z' ),
302+
( 2, 1, 1, 'Title for version 2 from 2525-01-04', '2525-01-04T00:00:12Z' ),
303+
( 2, 2, 2, 'Abstract for version 2 from 2525-01-04', '2525-01-04T00:00:13Z' );
304+
`);
305+
await agent
306+
.get('/proposals/1/?includeFieldsAndValues=true')
307+
.set(dummyApiKey)
308+
.expect(
309+
200,
310+
{
311+
id: 1,
312+
applicantId: 2,
313+
opportunityId: 1,
314+
externalId: 'proposal-2525-01-04T00Z',
315+
createdAt: '2525-01-04T00:00:07.000Z',
316+
versions: [
317+
{
318+
id: 2,
319+
proposalId: 1,
320+
applicationFormId: 1,
321+
version: 2,
322+
createdAt: '2525-01-04T00:00:09.000Z',
323+
fieldValues: [
324+
{
325+
id: 3,
326+
proposalVersionId: 2,
327+
applicationFormFieldId: 1,
328+
position: 1,
329+
value: 'Title for version 2 from 2525-01-04',
330+
createdAt: '2525-01-04T00:00:12.000Z',
331+
applicationFormField: {
332+
id: 1,
333+
applicationFormId: 1,
334+
canonicalFieldId: 2,
335+
position: 1,
336+
label: 'Short summary or title',
337+
createdAt: '2525-01-04T00:00:05.000Z',
338+
},
339+
},
340+
{
341+
id: 4,
342+
proposalVersionId: 2,
343+
applicationFormFieldId: 2,
344+
position: 2,
345+
value: 'Abstract for version 2 from 2525-01-04',
346+
createdAt: '2525-01-04T00:00:13.000Z',
347+
applicationFormField: {
348+
id: 2,
349+
applicationFormId: 1,
350+
canonicalFieldId: 1,
351+
position: 2,
352+
label: 'Long summary or abstract',
353+
createdAt: '2525-01-04T00:00:06.000Z',
354+
},
355+
},
356+
],
357+
},
358+
{
359+
id: 1,
360+
proposalId: 1,
361+
applicationFormId: 1,
362+
version: 1,
363+
createdAt: '2525-01-04T00:00:08.000Z',
364+
fieldValues: [
365+
{
366+
id: 1,
367+
proposalVersionId: 1,
368+
applicationFormFieldId: 1,
369+
position: 1,
370+
value: 'Title for version 1 from 2525-01-04',
371+
createdAt: '2525-01-04T00:00:10.000Z',
372+
applicationFormField: {
373+
id: 1,
374+
applicationFormId: 1,
375+
canonicalFieldId: 2,
376+
position: 1,
377+
label: 'Short summary or title',
378+
createdAt: '2525-01-04T00:00:05.000Z',
379+
},
380+
},
381+
{
382+
id: 2,
383+
proposalVersionId: 1,
384+
applicationFormFieldId: 2,
385+
position: 2,
386+
value: 'Abstract for version 1 from 2525-01-04',
387+
createdAt: '2525-01-04T00:00:11.000Z',
388+
applicationFormField: {
389+
id: 2,
390+
applicationFormId: 1,
391+
canonicalFieldId: 1,
392+
position: 2,
393+
label: 'Long summary or abstract',
394+
createdAt: '2525-01-04T00:00:06.000Z',
395+
},
396+
},
397+
],
398+
},
399+
],
400+
},
401+
);
402+
});
403+
202404
it('should error if the database returns an unexpected data structure', async () => {
203405
jest.spyOn(db, 'sql')
204406
.mockImplementationOnce(async () => ({
@@ -255,6 +457,105 @@ describe('/proposals', () => {
255457
});
256458
});
257459

460+
it('returns 404 when given id is not present and includeFieldsAndValues=true', async () => {
461+
await agent
462+
.get('/proposals/9002?includeFieldsAndValues=true')
463+
.set(dummyApiKey)
464+
.expect(404, {
465+
name: 'NotFoundError',
466+
message: 'Not found. Find existing proposals by calling with no parameters.',
467+
details: [
468+
{
469+
name: 'NotFoundError',
470+
status: 404,
471+
},
472+
],
473+
});
474+
});
475+
476+
it('should error if the database returns an unexpected data structure when includeFieldsAndValues=true', async () => {
477+
jest.spyOn(db, 'sql')
478+
.mockImplementationOnce(async () => ({
479+
rows: [{ foo: 'not a valid result' }],
480+
}) as Result<object>);
481+
const result = await agent
482+
.get('/proposals/9003?includeFieldsAndValues=true')
483+
.set(dummyApiKey)
484+
.expect(500);
485+
expect(result.body).toMatchObject({
486+
name: 'InternalValidationError',
487+
details: expect.any(Array) as unknown[],
488+
});
489+
});
490+
491+
it('returns 500 UnknownError if a generic Error is thrown when selecting and includeFieldsAndValues=true', async () => {
492+
jest.spyOn(db, 'sql')
493+
.mockImplementationOnce(async () => {
494+
throw new Error('This is unexpected');
495+
});
496+
const result = await agent
497+
.get('/proposals/9004?includeFieldsAndValues=true')
498+
.set(dummyApiKey)
499+
.expect(500);
500+
expect(result.body).toMatchObject({
501+
name: 'UnknownError',
502+
details: expect.any(Array) as unknown[],
503+
});
504+
});
505+
506+
it('returns 503 DatabaseError if db error is thrown when includeFieldsAndValues=true', async () => {
507+
await db.query(`
508+
INSERT INTO opportunities (
509+
title,
510+
created_at
511+
)
512+
VALUES
513+
( '🧳', '2525-01-04T00:00:14Z' )
514+
`);
515+
await db.query(`
516+
INSERT INTO applicants (
517+
external_id,
518+
opted_in,
519+
created_at
520+
)
521+
VALUES
522+
( '🐴', 'true', '2525-01-04T00:00:15Z' );
523+
`);
524+
await db.query(`
525+
INSERT INTO proposals (
526+
applicant_id,
527+
external_id,
528+
opportunity_id,
529+
created_at
530+
)
531+
VALUES
532+
( 1, 'proposal-🧳-🐴', 1, '2525-01-04T00:00:16Z' );
533+
`);
534+
jest.spyOn(db, 'sql')
535+
.mockImplementationOnce(async () => {
536+
throw new TinyPgError(
537+
'Something went wrong',
538+
undefined,
539+
{
540+
error: {
541+
code: PostgresErrorCode.INSUFFICIENT_RESOURCES,
542+
},
543+
},
544+
);
545+
});
546+
const result = await agent
547+
.get('/proposals/1?includeFieldsAndValues=true')
548+
.type('application/json')
549+
.set(dummyApiKey)
550+
.expect(503);
551+
expect(result.body).toMatchObject({
552+
name: 'DatabaseError',
553+
details: [{
554+
code: PostgresErrorCode.INSUFFICIENT_RESOURCES,
555+
}],
556+
});
557+
});
558+
258559
describe('POST /', () => {
259560
it('creates exactly one proposal', async () => {
260561
await db.query(`
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
SELECT aff.id AS "id",
2+
aff.application_form_id AS "applicationFormId",
3+
aff.canonical_field_id AS "canonicalFieldId",
4+
aff.position AS "position",
5+
aff.label AS "label",
6+
aff.created_at AS "createdAt"
7+
FROM application_form_fields aff
8+
INNER JOIN proposal_field_values pfv
9+
ON pfv.application_form_field_id = aff.id
10+
INNER JOIN proposal_versions pv
11+
ON pv.id = pfv.proposal_version_id
12+
WHERE pv.proposal_id = :proposalId
13+
ORDER BY pv.version DESC, pfv.position;
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
SELECT pfv.id AS "id",
2+
pfv.proposal_version_id AS "proposalVersionId",
3+
pfv.application_form_field_id AS "applicationFormFieldId",
4+
pfv.value AS "value",
5+
pfv.position AS "position",
6+
pfv.created_at AS "createdAt"
7+
FROM proposal_field_values pfv
8+
INNER JOIN proposal_versions pv
9+
ON pv.id = pfv.proposal_version_id
10+
WHERE pv.proposal_id = :proposalId
11+
ORDER BY pv.version DESC, pfv.position;
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
SELECT pv.id AS "id",
2+
pv.proposal_id AS "proposalId",
3+
pv.application_form_id AS "applicationFormId",
4+
pv.version AS "version",
5+
pv.created_at AS "createdAt"
6+
FROM proposal_versions pv
7+
WHERE pv.proposal_id = :proposalId
8+
ORDER BY pv.version DESC;

src/errors/AuthenticationError.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
export class AuthenticationError extends Error {
2-
public constructor(
3-
message: string,
4-
) {
5-
super(message);
1+
import { ErrorWithStatus } from './ErrorWithStatus';
2+
3+
export class AuthenticationError extends ErrorWithStatus {
4+
public constructor(message: string) {
5+
super(message, 401);
66
this.name = this.constructor.name;
77
}
88
}

src/errors/ErrorWithStatus.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export class ErrorWithStatus extends Error {
2+
public readonly status: number;
3+
4+
public constructor(
5+
message: string,
6+
status: number,
7+
) {
8+
super(message);
9+
this.name = this.constructor.name;
10+
this.status = status;
11+
}
12+
}

src/errors/NotFoundError.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { ErrorWithStatus } from './ErrorWithStatus';
2+
3+
export class NotFoundError extends ErrorWithStatus {
4+
public constructor(message: string) {
5+
super(message, 404);
6+
this.name = this.constructor.name;
7+
}
8+
}

0 commit comments

Comments
 (0)