@defra/forms-engine-plugin 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.server/server/plugins/engine/README.md +56 -0
  2. package/.server/server/plugins/engine/configureEnginePlugin.d.ts +2 -1
  3. package/.server/server/plugins/engine/configureEnginePlugin.js +1 -1
  4. package/.server/server/plugins/engine/configureEnginePlugin.js.map +1 -1
  5. package/.server/server/plugins/engine/plugin.d.ts +2 -27
  6. package/.server/server/plugins/engine/plugin.js +17 -594
  7. package/.server/server/plugins/engine/plugin.js.map +1 -1
  8. package/.server/server/plugins/engine/registrationOptions.d.ts +1 -0
  9. package/.server/server/plugins/engine/registrationOptions.js +2 -0
  10. package/.server/server/plugins/engine/registrationOptions.js.map +1 -0
  11. package/.server/server/plugins/engine/routes/file-upload.d.ts +4 -0
  12. package/.server/server/plugins/engine/routes/file-upload.js +41 -0
  13. package/.server/server/plugins/engine/routes/file-upload.js.map +1 -0
  14. package/.server/server/plugins/engine/routes/index.d.ts +7 -0
  15. package/.server/server/plugins/engine/routes/index.js +141 -0
  16. package/.server/server/plugins/engine/routes/index.js.map +1 -0
  17. package/.server/server/plugins/engine/routes/questions.d.ts +3 -0
  18. package/.server/server/plugins/engine/routes/questions.js +168 -0
  19. package/.server/server/plugins/engine/routes/questions.js.map +1 -0
  20. package/.server/server/plugins/engine/routes/repeaters/item-delete.d.ts +3 -0
  21. package/.server/server/plugins/engine/routes/repeaters/item-delete.js +106 -0
  22. package/.server/server/plugins/engine/routes/repeaters/item-delete.js.map +1 -0
  23. package/.server/server/plugins/engine/routes/repeaters/summary.d.ts +3 -0
  24. package/.server/server/plugins/engine/routes/repeaters/summary.js +98 -0
  25. package/.server/server/plugins/engine/routes/repeaters/summary.js.map +1 -0
  26. package/.server/server/plugins/engine/types.d.ts +19 -1
  27. package/.server/server/plugins/engine/types.js.map +1 -1
  28. package/.server/server/plugins/engine/vision.d.ts +12 -0
  29. package/.server/server/plugins/engine/vision.js +55 -0
  30. package/.server/server/plugins/engine/vision.js.map +1 -0
  31. package/.server/server/services/cacheService.d.ts +12 -3
  32. package/.server/server/services/cacheService.js +35 -8
  33. package/.server/server/services/cacheService.js.map +1 -1
  34. package/package.json +3 -3
  35. package/src/server/plugins/engine/README.md +56 -0
  36. package/src/server/plugins/engine/configureEnginePlugin.ts +3 -5
  37. package/src/server/plugins/engine/plugin.ts +35 -765
  38. package/src/server/plugins/engine/registrationOptions.ts +0 -0
  39. package/src/server/plugins/engine/routes/file-upload.ts +54 -0
  40. package/src/server/plugins/engine/routes/index.ts +187 -0
  41. package/src/server/plugins/engine/routes/questions.ts +208 -0
  42. package/src/server/plugins/engine/routes/repeaters/item-delete.ts +157 -0
  43. package/src/server/plugins/engine/routes/repeaters/summary.ts +137 -0
  44. package/src/server/plugins/engine/types.ts +26 -1
  45. package/src/server/plugins/engine/vision.ts +95 -0
  46. package/src/server/services/cacheService.test.ts +98 -2
  47. package/src/server/services/cacheService.ts +57 -8
@@ -0,0 +1,54 @@
1
+ import { getErrorMessage } from '@defra/forms-model'
2
+ import { type ResponseToolkit, type ServerRoute } from '@hapi/hapi'
3
+ import Joi from 'joi'
4
+
5
+ import { getUploadStatus } from '~/src/server/plugins/engine/services/uploadService.js'
6
+ import {
7
+ type FormRequest,
8
+ type FormRequestRefs
9
+ } from '~/src/server/routes/types.js'
10
+
11
+ export async function getHandler(
12
+ request: FormRequest,
13
+ h: Pick<ResponseToolkit, 'response'>
14
+ ) {
15
+ const { uploadId } = request.params as unknown as {
16
+ uploadId: string
17
+ }
18
+ try {
19
+ const status = await getUploadStatus(uploadId)
20
+
21
+ if (!status) {
22
+ return h.response({ error: 'Status check failed' }).code(400)
23
+ }
24
+
25
+ return h.response(status)
26
+ } catch (error) {
27
+ const errMsg = getErrorMessage(error)
28
+ request.logger.error(
29
+ errMsg,
30
+ `[uploadStatusFailed] Upload status check failed for uploadId: ${uploadId} - ${errMsg}`
31
+ )
32
+ return h.response({ error: 'Status check error' }).code(500)
33
+ }
34
+ }
35
+
36
+ export function getRoutes(): ServerRoute<FormRequestRefs>[] {
37
+ return [
38
+ {
39
+ method: 'get',
40
+ path: '/upload-status/{uploadId}',
41
+ handler: getHandler,
42
+ options: {
43
+ plugins: {
44
+ crumb: false
45
+ },
46
+ validate: {
47
+ params: Joi.object().keys({
48
+ uploadId: Joi.string().guid().required()
49
+ })
50
+ }
51
+ }
52
+ }
53
+ ]
54
+ }
@@ -0,0 +1,187 @@
1
+ import Boom from '@hapi/boom'
2
+ import {
3
+ type ResponseObject,
4
+ type ResponseToolkit,
5
+ type Server
6
+ } from '@hapi/hapi'
7
+ import { isEqual } from 'date-fns'
8
+
9
+ import { PREVIEW_PATH_PREFIX } from '~/src/server/constants.js'
10
+ import {
11
+ checkEmailAddressForLiveFormSubmission,
12
+ checkFormStatus,
13
+ findPage,
14
+ getCacheService,
15
+ getPage,
16
+ getStartPath,
17
+ proceed
18
+ } from '~/src/server/plugins/engine/helpers.js'
19
+ import { FormModel } from '~/src/server/plugins/engine/models/index.js'
20
+ import { type PageControllerClass } from '~/src/server/plugins/engine/pageControllers/helpers.js'
21
+ import { generateUniqueReference } from '~/src/server/plugins/engine/referenceNumbers.js'
22
+ import * as defaultServices from '~/src/server/plugins/engine/services/index.js'
23
+ import {
24
+ type FormContext,
25
+ type PluginOptions
26
+ } from '~/src/server/plugins/engine/types.js'
27
+ import {
28
+ type FormRequest,
29
+ type FormRequestPayload
30
+ } from '~/src/server/routes/types.js'
31
+
32
+ export async function redirectOrMakeHandler(
33
+ request: FormRequest | FormRequestPayload,
34
+ h: Pick<ResponseToolkit, 'redirect' | 'view'>,
35
+ makeHandler: (
36
+ page: PageControllerClass,
37
+ context: FormContext
38
+ ) => ResponseObject | Promise<ResponseObject>
39
+ ) {
40
+ const { app, params } = request
41
+ const { model } = app
42
+
43
+ if (!model) {
44
+ throw Boom.notFound(`No model found for /${params.path}`)
45
+ }
46
+
47
+ const cacheService = getCacheService(request.server)
48
+ const page = getPage(model, request)
49
+ let state = await page.getState(request)
50
+
51
+ if (!state.$$__referenceNumber) {
52
+ const prefix = model.def.metadata?.referenceNumberPrefix ?? ''
53
+
54
+ if (typeof prefix !== 'string') {
55
+ throw Boom.badImplementation(
56
+ 'Reference number prefix must be a string or undefined'
57
+ )
58
+ }
59
+
60
+ const referenceNumber = generateUniqueReference(prefix)
61
+ state = await page.mergeState(request, state, {
62
+ $$__referenceNumber: referenceNumber
63
+ })
64
+ }
65
+
66
+ const flash = cacheService.getFlash(request)
67
+ const context = model.getFormContext(request, state, flash?.errors)
68
+ const relevantPath = page.getRelevantPath(request, context)
69
+ const summaryPath = page.getSummaryPath()
70
+
71
+ // Return handler for relevant pages or preview URL direct access
72
+ if (relevantPath.startsWith(page.path) || context.isForceAccess) {
73
+ return makeHandler(page, context)
74
+ }
75
+
76
+ // Redirect back to last relevant page
77
+ const redirectTo = findPage(model, relevantPath)
78
+
79
+ // Set the return URL unless an exit page
80
+ if (redirectTo?.next.length) {
81
+ request.query.returnUrl = page.getHref(summaryPath)
82
+ }
83
+
84
+ return proceed(request, h, page.getHref(relevantPath))
85
+ }
86
+
87
+ export function makeLoadFormPreHandler(server: Server, options: PluginOptions) {
88
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- hapi types are wrong
89
+ const prefix = server.realm.modifiers.route.prefix ?? ''
90
+
91
+ const { services = defaultServices, controllers } = options
92
+
93
+ const { formsService } = services
94
+
95
+ async function handler(
96
+ request: FormRequest | FormRequestPayload,
97
+ h: ResponseToolkit
98
+ ) {
99
+ if (server.app.model) {
100
+ request.app.model = server.app.model
101
+
102
+ return h.continue
103
+ }
104
+
105
+ const { params } = request
106
+ const { slug } = params
107
+ const { isPreview, state: formState } = checkFormStatus(params)
108
+
109
+ // Get the form metadata using the `slug` param
110
+ const metadata = await formsService.getFormMetadata(slug)
111
+
112
+ const { id, [formState]: state } = metadata
113
+
114
+ // Check the metadata supports the requested state
115
+ if (!state) {
116
+ throw Boom.notFound(`No '${formState}' state for form metadata ${id}`)
117
+ }
118
+
119
+ // Cache the models based on id, state and whether
120
+ // it's a preview or not. There could be up to 3 models
121
+ // cached for a single form:
122
+ // "{id}_live_false" (live/live)
123
+ // "{id}_live_true" (live/preview)
124
+ // "{id}_draft_true" (draft/preview)
125
+ const key = `${id}_${formState}_${isPreview}`
126
+ let item = server.app.models.get(key)
127
+
128
+ if (!item || !isEqual(item.updatedAt, state.updatedAt)) {
129
+ server.logger.info(`Getting form definition ${id} (${slug}) ${formState}`)
130
+
131
+ // Get the form definition using the `id` from the metadata
132
+ const definition = await formsService.getFormDefinition(id, formState)
133
+
134
+ if (!definition) {
135
+ throw Boom.notFound(
136
+ `No definition found for form metadata ${id} (${slug}) ${formState}`
137
+ )
138
+ }
139
+
140
+ const emailAddress = metadata.notificationEmail ?? definition.outputEmail
141
+
142
+ checkEmailAddressForLiveFormSubmission(emailAddress, isPreview)
143
+
144
+ // Build the form model
145
+ server.logger.info(
146
+ `Building model for form definition ${id} (${slug}) ${formState}`
147
+ )
148
+
149
+ // Set up the basePath for the model
150
+ const basePath = (
151
+ isPreview
152
+ ? `${prefix}${PREVIEW_PATH_PREFIX}/${formState}/${slug}`
153
+ : `${prefix}/${slug}`
154
+ ).substring(1)
155
+
156
+ // Construct the form model
157
+ const model = new FormModel(
158
+ definition,
159
+ { basePath },
160
+ services,
161
+ controllers
162
+ )
163
+
164
+ // Create new item and add it to the item cache
165
+ item = { model, updatedAt: state.updatedAt }
166
+ server.app.models.set(key, item)
167
+ }
168
+
169
+ // Assign the model to the request data
170
+ // for use in the downstream handler
171
+ request.app.model = item.model
172
+
173
+ return h.continue
174
+ }
175
+
176
+ return handler
177
+ }
178
+
179
+ export function dispatchHandler(
180
+ request: FormRequest,
181
+ h: Pick<ResponseToolkit, 'redirect' | 'view'>
182
+ ) {
183
+ const { model } = request.app
184
+
185
+ const servicePath = model ? `/${model.basePath}` : ''
186
+ return proceed(request, h, `${servicePath}${getStartPath(model)}`)
187
+ }
@@ -0,0 +1,208 @@
1
+ import { hasFormComponents, slugSchema } from '@defra/forms-model'
2
+ import Boom from '@hapi/boom'
3
+ import {
4
+ type ResponseToolkit,
5
+ type RouteOptions,
6
+ type ServerRoute
7
+ } from '@hapi/hapi'
8
+ import Joi from 'joi'
9
+
10
+ import {
11
+ normalisePath,
12
+ proceed,
13
+ redirectPath
14
+ } from '~/src/server/plugins/engine/helpers.js'
15
+ import { SummaryViewModel } from '~/src/server/plugins/engine/models/index.js'
16
+ import { format } from '~/src/server/plugins/engine/outputFormatters/machine/v1.js'
17
+ import { getFormSubmissionData } from '~/src/server/plugins/engine/pageControllers/SummaryPageController.js'
18
+ import {
19
+ dispatchHandler,
20
+ redirectOrMakeHandler
21
+ } from '~/src/server/plugins/engine/routes/index.js'
22
+ import {
23
+ type FormRequest,
24
+ type FormRequestPayload,
25
+ type FormRequestPayloadRefs,
26
+ type FormRequestRefs
27
+ } from '~/src/server/routes/types.js'
28
+ import {
29
+ actionSchema,
30
+ crumbSchema,
31
+ itemIdSchema,
32
+ pathSchema,
33
+ stateSchema
34
+ } from '~/src/server/schemas/index.js'
35
+ import * as httpService from '~/src/server/services/httpService.js'
36
+
37
+ function getHandler(
38
+ request: FormRequest,
39
+ h: Pick<ResponseToolkit, 'redirect' | 'view'>
40
+ ) {
41
+ const { params } = request
42
+
43
+ if (normalisePath(params.path) === '') {
44
+ return dispatchHandler(request, h)
45
+ }
46
+
47
+ return redirectOrMakeHandler(request, h, async (page, context) => {
48
+ // Check for a page onLoad HTTP event and if one exists,
49
+ // call it and assign the response to the context data
50
+ const { events } = page
51
+ const { model } = request.app
52
+
53
+ if (!model) {
54
+ throw Boom.notFound(`No model found for /${params.path}`)
55
+ }
56
+
57
+ if (events?.onLoad && events.onLoad.type === 'http') {
58
+ const { options } = events.onLoad
59
+ const { url } = options
60
+
61
+ // TODO: Update structured data POST payload with when helper
62
+ // is updated to removing the dependency on `SummaryViewModel` etc.
63
+ const viewModel = new SummaryViewModel(request, page, context)
64
+ const items = getFormSubmissionData(viewModel.context, viewModel.details)
65
+
66
+ // @ts-expect-error - function signature will be refactored in the next iteration of the formatter
67
+ const payload = format(items, model, undefined, undefined)
68
+
69
+ const { payload: response } = await httpService.postJson(url, {
70
+ payload
71
+ })
72
+
73
+ Object.assign(context.data, response)
74
+ }
75
+
76
+ return page.makeGetRouteHandler()(request, context, h)
77
+ })
78
+ }
79
+
80
+ function postHandler(
81
+ request: FormRequestPayload,
82
+ h: Pick<ResponseToolkit, 'redirect' | 'view'>
83
+ ) {
84
+ const { query } = request
85
+
86
+ return redirectOrMakeHandler(request, h, (page, context) => {
87
+ const { pageDef } = page
88
+ const { isForceAccess } = context
89
+
90
+ // Redirect to GET for preview URL direct access
91
+ if (isForceAccess && !hasFormComponents(pageDef)) {
92
+ return proceed(request, h, redirectPath(page.href, query))
93
+ }
94
+
95
+ return page.makePostRouteHandler()(request, context, h)
96
+ })
97
+ }
98
+
99
+ export function getRoutes(
100
+ getRouteOptions: RouteOptions<FormRequestRefs>,
101
+ postRouteOptions: RouteOptions<FormRequestPayloadRefs>
102
+ ): (ServerRoute<FormRequestRefs> | ServerRoute<FormRequestPayloadRefs>)[] {
103
+ return [
104
+ {
105
+ method: 'get',
106
+ path: '/{slug}',
107
+ handler: getHandler,
108
+ options: {
109
+ ...getRouteOptions,
110
+ validate: {
111
+ params: Joi.object().keys({
112
+ slug: slugSchema
113
+ })
114
+ }
115
+ }
116
+ },
117
+ {
118
+ method: 'get',
119
+ path: '/preview/{state}/{slug}',
120
+ handler: dispatchHandler,
121
+ options: {
122
+ ...getRouteOptions,
123
+ validate: {
124
+ params: Joi.object().keys({
125
+ state: stateSchema,
126
+ slug: slugSchema
127
+ })
128
+ }
129
+ }
130
+ },
131
+ {
132
+ method: 'get',
133
+ path: '/{slug}/{path}/{itemId?}',
134
+ handler: getHandler,
135
+ options: {
136
+ ...getRouteOptions,
137
+ validate: {
138
+ params: Joi.object().keys({
139
+ slug: slugSchema,
140
+ path: pathSchema,
141
+ itemId: itemIdSchema.optional()
142
+ })
143
+ }
144
+ }
145
+ },
146
+ {
147
+ method: 'get',
148
+ path: '/preview/{state}/{slug}/{path}/{itemId?}',
149
+ handler: getHandler,
150
+ options: {
151
+ ...getRouteOptions,
152
+ validate: {
153
+ params: Joi.object().keys({
154
+ state: stateSchema,
155
+ slug: slugSchema,
156
+ path: pathSchema,
157
+ itemId: itemIdSchema.optional()
158
+ })
159
+ }
160
+ }
161
+ },
162
+ {
163
+ method: 'post',
164
+ path: '/{slug}/{path}/{itemId?}',
165
+ handler: postHandler,
166
+ options: {
167
+ ...postRouteOptions,
168
+ validate: {
169
+ params: Joi.object().keys({
170
+ slug: slugSchema,
171
+ path: pathSchema,
172
+ itemId: itemIdSchema.optional()
173
+ }),
174
+ payload: Joi.object()
175
+ .keys({
176
+ crumb: crumbSchema,
177
+ action: actionSchema
178
+ })
179
+ .unknown(true)
180
+ .required()
181
+ }
182
+ }
183
+ },
184
+ {
185
+ method: 'post',
186
+ path: '/preview/{state}/{slug}/{path}/{itemId?}',
187
+ handler: postHandler,
188
+ options: {
189
+ ...postRouteOptions,
190
+ validate: {
191
+ params: Joi.object().keys({
192
+ state: stateSchema,
193
+ slug: slugSchema,
194
+ path: pathSchema,
195
+ itemId: itemIdSchema.optional()
196
+ }),
197
+ payload: Joi.object()
198
+ .keys({
199
+ crumb: crumbSchema,
200
+ action: actionSchema
201
+ })
202
+ .unknown(true)
203
+ .required()
204
+ }
205
+ }
206
+ }
207
+ ]
208
+ }
@@ -0,0 +1,157 @@
1
+ import { slugSchema } from '@defra/forms-model'
2
+ import Boom from '@hapi/boom'
3
+ import {
4
+ type ResponseToolkit,
5
+ type RouteOptions,
6
+ type ServerRoute
7
+ } from '@hapi/hapi'
8
+ import Joi from 'joi'
9
+
10
+ import { FileUploadPageController } from '~/src/server/plugins/engine/pageControllers/FileUploadPageController.js'
11
+ import { RepeatPageController } from '~/src/server/plugins/engine/pageControllers/RepeatPageController.js'
12
+ import { redirectOrMakeHandler } from '~/src/server/plugins/engine/routes/index.js'
13
+ import {
14
+ type FormRequest,
15
+ type FormRequestPayload,
16
+ type FormRequestPayloadRefs,
17
+ type FormRequestRefs
18
+ } from '~/src/server/routes/types.js'
19
+ import {
20
+ actionSchema,
21
+ confirmSchema,
22
+ crumbSchema,
23
+ itemIdSchema,
24
+ pathSchema,
25
+ stateSchema
26
+ } from '~/src/server/schemas/index.js'
27
+
28
+ // Item delete GET route
29
+ function getHandler(
30
+ request: FormRequest,
31
+ h: Pick<ResponseToolkit, 'redirect' | 'view'>
32
+ ) {
33
+ const { params } = request
34
+
35
+ return redirectOrMakeHandler(request, h, (page, context) => {
36
+ if (
37
+ !(
38
+ page instanceof RepeatPageController ||
39
+ page instanceof FileUploadPageController
40
+ )
41
+ ) {
42
+ throw Boom.notFound(`No page found for /${params.path}`)
43
+ }
44
+
45
+ return page.makeGetItemDeleteRouteHandler()(request, context, h)
46
+ })
47
+ }
48
+
49
+ function postHandler(
50
+ request: FormRequestPayload,
51
+ h: Pick<ResponseToolkit, 'redirect' | 'view'>
52
+ ) {
53
+ const { params } = request
54
+
55
+ return redirectOrMakeHandler(request, h, (page, context) => {
56
+ const { isForceAccess } = context
57
+
58
+ if (
59
+ isForceAccess ||
60
+ !(
61
+ page instanceof RepeatPageController ||
62
+ page instanceof FileUploadPageController
63
+ )
64
+ ) {
65
+ throw Boom.notFound(`No page found for /${params.path}`)
66
+ }
67
+
68
+ return page.makePostItemDeleteRouteHandler()(request, context, h)
69
+ })
70
+ }
71
+
72
+ export function getRoutes(
73
+ getRouteOptions: RouteOptions<FormRequestRefs>,
74
+ postRouteOptions: RouteOptions<FormRequestPayloadRefs>
75
+ ): (ServerRoute<FormRequestRefs> | ServerRoute<FormRequestPayloadRefs>)[] {
76
+ return [
77
+ {
78
+ method: 'get',
79
+ path: '/{slug}/{path}/{itemId}/confirm-delete',
80
+ handler: getHandler,
81
+ options: {
82
+ ...getRouteOptions,
83
+ validate: {
84
+ params: Joi.object().keys({
85
+ slug: slugSchema,
86
+ path: pathSchema,
87
+ itemId: itemIdSchema
88
+ })
89
+ }
90
+ }
91
+ },
92
+
93
+ {
94
+ method: 'get',
95
+ path: '/preview/{state}/{slug}/{path}/{itemId}/confirm-delete',
96
+ handler: getHandler,
97
+ options: {
98
+ ...getRouteOptions,
99
+ validate: {
100
+ params: Joi.object().keys({
101
+ state: stateSchema,
102
+ slug: slugSchema,
103
+ path: pathSchema,
104
+ itemId: itemIdSchema
105
+ })
106
+ }
107
+ }
108
+ },
109
+
110
+ {
111
+ method: 'post',
112
+ path: '/{slug}/{path}/{itemId}/confirm-delete',
113
+ handler: postHandler,
114
+ options: {
115
+ ...postRouteOptions,
116
+ validate: {
117
+ params: Joi.object().keys({
118
+ slug: slugSchema,
119
+ path: pathSchema,
120
+ itemId: itemIdSchema
121
+ }),
122
+ payload: Joi.object()
123
+ .keys({
124
+ crumb: crumbSchema,
125
+ action: actionSchema,
126
+ confirm: confirmSchema
127
+ })
128
+ .required()
129
+ }
130
+ }
131
+ },
132
+
133
+ {
134
+ method: 'post',
135
+ path: '/preview/{state}/{slug}/{path}/{itemId}/confirm-delete',
136
+ handler: postHandler,
137
+ options: {
138
+ ...postRouteOptions,
139
+ validate: {
140
+ params: Joi.object().keys({
141
+ state: stateSchema,
142
+ slug: slugSchema,
143
+ path: pathSchema,
144
+ itemId: itemIdSchema
145
+ }),
146
+ payload: Joi.object()
147
+ .keys({
148
+ crumb: crumbSchema,
149
+ action: actionSchema,
150
+ confirm: confirmSchema
151
+ })
152
+ .required()
153
+ }
154
+ }
155
+ }
156
+ ]
157
+ }