Skip to content

Commit 80bf78b

Browse files
authored
Merge pull request #502 from DEFRA/feat/df-975-metrics
feat/df-975: Added report endpoint
2 parents ad5c9f1 + 3860254 commit 80bf78b

11 files changed

Lines changed: 381 additions & 7 deletions

File tree

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
"@aws-sdk/client-sqs": "^3.982.0",
4545
"@aws-sdk/s3-request-presigner": "^3.679.0",
4646
"@defra/forms-engine-plugin": "^4.5.5",
47-
"@defra/forms-model": "^3.0.642",
47+
"@defra/forms-model": "^3.0.644",
4848
"@defra/hapi-tracing": "^1.12.0",
4949
"@elastic/ecs-pino-format": "^1.5.0",
5050
"@hapi/boom": "^10.0.1",

src/api/types.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,10 @@
7676
* @property {number} numberOfRejectedFiles - Total number of files that have been rejected by the uploader
7777
*/
7878

79+
/**
80+
* @typedef {{ Query: { date: Date }}} GetReportTimelineRequest
81+
*/
82+
7983
/**
8084
* @import { SaveAndExitRecord } from '@defra/forms-model'
8185
* @import { FormAdapterSubmissionMessagePayload } from '@defra/forms-engine-plugin/engine/types.js'

src/models/form.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,17 @@ export const getSubmissionByReferenceResponseSchema = Joi.object()
6969
.concat(formAdapterSubmissionMessagePayloadSchema)
7070
.label('getSubmissionByReferenceResponseSchema')
7171

72+
export const generateReportTimelineResponseSchema = Joi.object({
73+
timeline: Joi.array().items({
74+
type: Joi.string().required(),
75+
formId: Joi.string().required(),
76+
formStatus: Joi.string().required(),
77+
metricName: Joi.string().required(),
78+
metricValue: Joi.number().required(),
79+
createdAt: Joi.date().required()
80+
})
81+
}).label('generateReportTimelineResponse')
82+
7283
/**
7384
* @import { FormSubmissionDocument } from '~/src/api/types.js'
7485
*/

src/repositories/__stubs__/submission.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,28 @@ export const STUB_FORM_ID = '688131eeff67f889d52c66cc'
77
export const STUB_SUBMISSION_REF = '365-DFR-C67'
88
export const STUB_SUBMISSION_RECORD_ID = '68d284ef5fa1a0fb2ede066a'
99

10+
/**
11+
* @param {string} formId
12+
* @param {FormStatus} formStatus
13+
* @param {Date} timestamp
14+
* @returns {WithId<FormSubmissionDocument>}
15+
*/
16+
export function buildCustomisedSubmissionDocument(
17+
formId,
18+
formStatus,
19+
timestamp
20+
) {
21+
const doc = /** @type {WithId<FormSubmissionDocument>} */ ({
22+
...buildFormAdapterSubmissionMessagePayloadStub(),
23+
recordCreatedAt: new Date(),
24+
expireAt: addDays(new Date(), 28)
25+
})
26+
doc.meta.formId = formId
27+
doc.meta.timestamp = timestamp
28+
doc.meta.status = formStatus
29+
return doc
30+
}
31+
1032
/**
1133
* @returns {WithId<FormSubmissionDocument>}
1234
*/

src/repositories/submission-repository.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,43 @@ export function getSubmissionRecords(formId, filter) {
3636
}
3737
}
3838

39+
/**
40+
* Gets all submission records for a single day
41+
* @param {Date} date - the specified day
42+
* @returns { FindCursor<WithId<FormSubmissionDocument>> }
43+
*/
44+
export function getSubmissionRecordsForDate(date) {
45+
logger.info(`Reading submission records for date ${date.toISOString()}`)
46+
47+
const coll = /** @type {Collection<FormSubmissionDocument>} */ (
48+
db.collection(SUBMISSIONS_COLLECTION_NAME)
49+
)
50+
51+
const withoutTime = date.toISOString().substring(0, 10)
52+
const startOfDay = `${withoutTime}T00:00:00.000Z`
53+
const endOfDay = `${withoutTime}T23:59:59.999Z`
54+
try {
55+
const result = coll
56+
.find({
57+
'meta.timestamp': {
58+
$gte: new Date(startOfDay),
59+
$lte: new Date(endOfDay)
60+
}
61+
})
62+
.sort('meta.timestamp', 'desc')
63+
64+
logger.info(`Read submission records for date ${date.toISOString()}`)
65+
66+
return result
67+
} catch (err) {
68+
logger.error(
69+
err,
70+
`Failed to read submission records for date ${date.toISOString()} - ${getErrorMessage(err)}`
71+
)
72+
throw err
73+
}
74+
}
75+
3976
/**
4077
* Creates a form submission record
4178
* @param {FormSubmissionDocument} document

src/repositories/submission-repository.test.js

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import {
88
import {
99
createSubmissionRecord,
1010
getSubmissionRecordByReference,
11-
getSubmissionRecords
11+
getSubmissionRecords,
12+
getSubmissionRecordsForDate
1213
} from '~/src/repositories/submission-repository.js'
1314

1415
const mockCollection = buildMockCollection()
@@ -125,4 +126,31 @@ describe('submission repository', () => {
125126
).rejects.toThrow(new Error('an error'))
126127
})
127128
})
129+
130+
describe('getSubmissionRecordsForDate', () => {
131+
it('should get submission records cursor', () => {
132+
mockCollection.find.mockReturnValueOnce({
133+
sort: jest.fn(() => {
134+
return { next: () => submissionDocument }
135+
})
136+
})
137+
const date = new Date('2026-02-15')
138+
const submissionRecord = getSubmissionRecordsForDate(date)
139+
expect(submissionRecord.next()).toEqual(submissionDocument)
140+
expect(mockCollection.find).toHaveBeenCalledWith({
141+
'meta.timestamp': {
142+
$gte: new Date('2026-02-15T00:00:00.000Z'),
143+
$lte: new Date('2026-02-15T23:59:59.999Z')
144+
}
145+
})
146+
})
147+
148+
it('should log and throw when error', () => {
149+
mockCollection.find.mockImplementationOnce(() => {
150+
throw new Error('db error')
151+
})
152+
const date = new Date()
153+
expect(() => getSubmissionRecordsForDate(date)).toThrow('db error')
154+
})
155+
})
128156
})

src/routes/form.js

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ import Joi from 'joi'
33

44
import {
55
formSubmitResponseSchema,
6+
generateReportTimelineResponseSchema,
67
getSavedLinkGoneSchema,
78
getSavedLinkResponseSchema,
89
magicLinkSchema,
910
validateSavedLinkResponseSchema
1011
} from '~/src/models/form.js'
1112
import { submit } from '~/src/services/file-service.js'
13+
import { generateReportTimeline } from '~/src/services/report.js'
1214
import {
1315
getSavedLinkDetails,
1416
validateSavedLinkCredentials
@@ -107,11 +109,40 @@ export default [
107109
}
108110
}
109111
}
112+
}),
113+
114+
/**
115+
* @type {ServerRoute<GetReportTimelineRequest>}
116+
*/
117+
({
118+
method: 'GET',
119+
path: '/report/timeline',
120+
handler(request) {
121+
const { date } = request.query
122+
123+
return generateReportTimeline(date)
124+
},
125+
options: {
126+
tags: ['api'],
127+
auth: false,
128+
validate: {
129+
query: Joi.object()
130+
.keys({
131+
date: Joi.date().required()
132+
})
133+
.label('getReportTimelineQuery')
134+
},
135+
response: {
136+
status: {
137+
200: generateReportTimelineResponseSchema
138+
}
139+
}
140+
}
110141
})
111142
]
112143

113144
/**
114145
* @import { ServerRoute } from '@hapi/hapi'
115146
* @import { SubmitPayload } from '@defra/forms-model'
116-
* @import { GetSavedLinkParams, ValidateSaveAndExit } from '~/src/api/types.js'
147+
* @import { GetSavedLinkParams, GetReportTimelineRequest, ValidateSaveAndExit } from '~/src/api/types.js'
117148
*/

src/routes/form.test.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { StatusCodes } from 'http-status-codes'
44

55
import { createServer } from '~/src/api/server.js'
66
import { submit } from '~/src/services/file-service.js'
7+
import { generateReportTimeline } from '~/src/services/report.js'
78
import {
89
getSavedLinkDetails,
910
validateSavedLinkCredentials
@@ -15,6 +16,7 @@ jest.mock('~/src/services/save-and-exit-service.js')
1516
jest.mock('~/src/tasks/receive-save-and-exit-messages.js')
1617
jest.mock('~/src/tasks/receive-submission-messages.js')
1718
jest.mock('~/src/services/submission-service.js')
19+
jest.mock('~/src/services/report.js')
1820
jest.mock('~/src/helpers/logging/logger.js', () => ({
1921
createLogger: () => ({
2022
error: jest.fn(),
@@ -253,6 +255,23 @@ describe('Forms route', () => {
253255
})
254256
})
255257
})
258+
259+
describe('report', () => {
260+
test('Testing GET /report/timeline route returns data', async () => {
261+
jest.mocked(generateReportTimeline).mockResolvedValueOnce({
262+
timeline: []
263+
})
264+
const response = await server.inject({
265+
method: 'GET',
266+
url: '/report/timeline?date=2025-05-04'
267+
})
268+
269+
expect(response.statusCode).toEqual(StatusCodes.OK)
270+
expect(response.result).toMatchObject({
271+
timeline: []
272+
})
273+
})
274+
})
256275
})
257276

258277
/**

src/services/report.js

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { FormMetricType, FormStatus, getErrorMessage } from '@defra/forms-model'
2+
3+
import { createLogger } from '~/src/helpers/logging/logger.js'
4+
import { getSubmissionRecordsForDate } from '~/src/repositories/submission-repository.js'
5+
6+
const logger = createLogger()
7+
8+
/**
9+
* @param {Map<string, number>} map
10+
* @param {string} formId
11+
*/
12+
function incrementFormCount(map, formId) {
13+
const current = map.get(formId) ?? 0
14+
map.set(formId, current + 1)
15+
}
16+
17+
/**
18+
* @param {any[]} timelineMetrics
19+
* @param {string} formId
20+
* @param {FormStatus} formStatus
21+
* @param {number} count
22+
* @param {Date} date
23+
*/
24+
function pushTimelineMetric(timelineMetrics, formId, formStatus, count, date) {
25+
timelineMetrics.push(
26+
/** @type {FormTimelineMetric} */ ({
27+
type: FormMetricType.TimelineMetric,
28+
formId,
29+
formStatus,
30+
metricName: 'Submissions',
31+
metricValue: count,
32+
createdAt: date
33+
})
34+
)
35+
}
36+
37+
/**
38+
* Generates a set of timeline metrics
39+
* @param {Date} date - date on which to gather the metrics for
40+
*/
41+
export async function generateReportTimeline(date) {
42+
logger.info(
43+
`[report] Generating timeline report for date ${date.toUTCString()}`
44+
)
45+
46+
try {
47+
const submissionsCursor = getSubmissionRecordsForDate(date)
48+
49+
const timelineMapDraft = new Map()
50+
const timelineMapLive = new Map()
51+
52+
for await (const submission of submissionsCursor) {
53+
const status = submission.meta.status
54+
if (status === FormStatus.Draft) {
55+
incrementFormCount(timelineMapDraft, submission.meta.formId)
56+
} else {
57+
incrementFormCount(timelineMapLive, submission.meta.formId)
58+
}
59+
}
60+
61+
const timelineMetrics = /** @type {FormTimelineMetric[]} */ ([])
62+
63+
if (timelineMapDraft.size) {
64+
for (const [formId, count] of timelineMapDraft) {
65+
pushTimelineMetric(
66+
timelineMetrics,
67+
formId,
68+
FormStatus.Draft,
69+
count,
70+
date
71+
)
72+
}
73+
}
74+
75+
if (timelineMapLive.size) {
76+
for (const [formId, count] of timelineMapLive) {
77+
pushTimelineMetric(
78+
timelineMetrics,
79+
formId,
80+
FormStatus.Live,
81+
count,
82+
date
83+
)
84+
}
85+
}
86+
87+
logger.info(
88+
`[report] Generated timeline report for date ${date.toString()}`
89+
)
90+
91+
return {
92+
timeline: timelineMetrics
93+
}
94+
} catch (err) {
95+
logger.error(
96+
err,
97+
`[report] Failed to generate timeline report for date ${date.toString()} - ${getErrorMessage(err)}`
98+
)
99+
100+
throw err
101+
}
102+
}
103+
104+
/**
105+
* @import { FormTimelineMetric } from '@defra/forms-model'
106+
*/

0 commit comments

Comments
 (0)