@defra/grants-reporting-publisher 0.0.3 → 0.1.1

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @defra/grants-reporting-publisher
2
2
 
3
+ ## 0.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - f8be355: Update readme with examples
8
+
9
+ ## 0.1.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 94ffa84: Add the schema definition for input events
14
+
3
15
  ## 0.0.3
4
16
 
5
17
  ### Patch Changes
package/EXAMPLES.md ADDED
@@ -0,0 +1,127 @@
1
+ # Usage Examples
2
+
3
+ This page provides examples of how to use the `@defra/grants-reporting-publisher` library to publish reporting events.
4
+
5
+ ## Configuration
6
+
7
+ To publish events, you need to provide a configuration object that includes an AWS SNS client and the target topic ARN.
8
+
9
+ ```javascript
10
+ import { SNSClient } from "@aws-sdk/client-sns";
11
+ import {
12
+ publishReportingEvent,
13
+ AGREEMENT_CREATED,
14
+ AGREEMENT_STATUS_CHANGED,
15
+ } from "@defra/grants-reporting-publisher";
16
+
17
+ const snsClient = new SNSClient({ region: "eu-west-2" });
18
+
19
+ const config = {
20
+ snsClient,
21
+ sns: {
22
+ topicArn: "arn:aws:sns:eu-west-2:123456789012:your-topic-name",
23
+ },
24
+ application: "YOUR_APPLICATION_CODE", // e.g., "GAS or grants-payments-service"
25
+ service: "your-service-name", // optional, defaults to "grants", just omit this for now
26
+ generateCorrelationId: true, // automatically generate a UUID for each event
27
+ };
28
+ ```
29
+
30
+ ## Event Types
31
+
32
+ ### Agreement Created (`AGREEMENT_CREATED`)
33
+
34
+ Use this event type when a new agreement is created.
35
+
36
+ ```javascript
37
+ const agreementCreatedEvent = {
38
+ eventData: {
39
+ eventType: AGREEMENT_CREATED,
40
+ agreementId: "AG-123456",
41
+ agreementType: "WOODLAND",
42
+ agreementStatus: "ACCEPTED",
43
+ agreementStartDate: "2026-01-01",
44
+ agreementEndDate: "2031-12-31",
45
+ agreementValue: 50000.0,
46
+ sbi: "200000001",
47
+ options: [
48
+ {
49
+ parcelReference: "SD8545-9935",
50
+ parcelSizeUnderAgreement: 10.5,
51
+ optionCode: "WD1",
52
+ optionYear: 1,
53
+ optionStartDate: "2026-01-01",
54
+ optionEndDate: "2026-12-31",
55
+ optionQuantity: 10.5,
56
+ optionValue: 10500.0,
57
+ },
58
+ ],
59
+ },
60
+ };
61
+
62
+ try {
63
+ const { messageId } = await publishReportingEvent(
64
+ agreementCreatedEvent,
65
+ config,
66
+ );
67
+ console.log(`Event published with Message ID: ${messageId}`);
68
+ } catch (error) {
69
+ console.error("Failed to publish event:", error.message);
70
+ }
71
+ ```
72
+
73
+ ### Agreement Status Changed (`AGREEMENT_STATUS_CHANGED`)
74
+
75
+ Use this event type when the status of an existing agreement changes.
76
+
77
+ ```javascript
78
+ const statusChangedEvent = {
79
+ eventData: {
80
+ eventType: AGREEMENT_STATUS_CHANGED,
81
+ agreementId: "AG-123456",
82
+ agreementStatus: "COMPLETED",
83
+ statusDate: new Date().toISOString(),
84
+ agreementStartDate: "2026-01-01",
85
+ agreementEndDate: "2031-12-31",
86
+ agreementValue: 50000.0,
87
+ userId: "user-uuid-123", // optional
88
+ },
89
+ };
90
+
91
+ try {
92
+ const { messageId } = await publishReportingEvent(statusChangedEvent, config);
93
+ console.log(`Event published with Message ID: ${messageId}`);
94
+ } catch (error) {
95
+ console.error("Failed to publish event:", error.message);
96
+ }
97
+ ```
98
+
99
+ ## Validation Only
100
+
101
+ If you only want to validate an event without publishing it:
102
+
103
+ ```javascript
104
+ import { validateReportingEvent } from "@defra/grants-reporting-publisher";
105
+
106
+ const event = {
107
+ correlationId: "your-correlation-id",
108
+ datetime: new Date().toISOString(),
109
+ version: "1.0.0",
110
+ application: "FCP",
111
+ service: "grants",
112
+ eventData: {
113
+ eventType: AGREEMENT_STATUS_CHANGED,
114
+ agreementId: "AG-123",
115
+ agreementStatus: "ACTIVE",
116
+ statusDate: new Date().toISOString(),
117
+ },
118
+ };
119
+
120
+ const { valid, errors, value } = validateReportingEvent(event);
121
+
122
+ if (!valid) {
123
+ console.error("Validation errors:", errors);
124
+ } else {
125
+ console.log("Event is valid:", value);
126
+ }
127
+ ```
package/README.md CHANGED
@@ -4,6 +4,8 @@ NPM module for providing validation and publishing capability for grants reporti
4
4
 
5
5
  ## Usage
6
6
 
7
+ Refer to the [Usage Examples](EXAMPLES.md) for detailed examples of how to publish reporting events.
8
+
7
9
  ### Installation
8
10
 
9
11
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@defra/grants-reporting-publisher",
3
- "version": "0.0.3",
3
+ "version": "0.1.1",
4
4
  "description": "NPM module for publishing grants reporting events",
5
5
  "engines": {
6
6
  "node": ">=24",
@@ -32,7 +32,8 @@
32
32
  ".": "./src/index.js",
33
33
  "./publish": "./src/publish.js",
34
34
  "./validate": "./src/validate.js",
35
- "./schema": "./src/schema.js"
35
+ "./schema": "./src/schema.js",
36
+ "./constants": "./src/constants.js"
36
37
  },
37
38
  "license": "OGL-UK-3.0",
38
39
  "bugs": {
@@ -0,0 +1,2 @@
1
+ export const AGREEMENT_CREATED = "AGREEMENT_CREATED";
2
+ export const AGREEMENT_STATUS_CHANGED = "AGREEMENT_STATUS_CHANGED";
package/src/index.js CHANGED
@@ -1 +1,2 @@
1
1
  export { validateReportingEvent } from "./validate.js";
2
+ export * from "./constants.js";
@@ -1,4 +1,5 @@
1
1
  import { publishReportingEvent } from "./publish.js";
2
+ import { AGREEMENT_STATUS_CHANGED } from "./constants.js";
2
3
 
3
4
  const mockSend = vi.hoisted(() =>
4
5
  vi.fn().mockResolvedValue({ MessageId: "msg-001" }),
@@ -22,8 +23,12 @@ const validEvent = {
22
23
  datetime: "2025-12-01T12:51:41.381Z",
23
24
  version: "1.0.0",
24
25
  application: "FCP001",
26
+ service: "grants",
25
27
  eventData: {
26
- status: "created",
28
+ eventType: AGREEMENT_STATUS_CHANGED,
29
+ agreementId: "WMP123456789",
30
+ agreementStatus: "created",
31
+ statusDate: "2025-12-01T12:51:41.381Z",
27
32
  },
28
33
  };
29
34
 
package/src/schema.js CHANGED
@@ -1,28 +1,62 @@
1
1
  import Joi from "joi";
2
+ import { AGREEMENT_CREATED, AGREEMENT_STATUS_CHANGED } from "./constants.js";
2
3
 
3
4
  const MAX_30 = 30;
4
5
  const MAX_50 = 50;
5
- const MAX_120 = 120;
6
6
 
7
7
  const accountId = Joi.alternatives()
8
8
  .try(Joi.string().max(MAX_50), Joi.number())
9
9
  .custom(String);
10
10
 
11
11
  export const eventSchema = Joi.object({
12
- user: Joi.string().max(MAX_50).allow(""),
13
- sessionId: Joi.string().max(MAX_50).allow(""),
14
12
  correlationId: Joi.string().max(MAX_50).required(),
15
13
  datetime: Joi.date().iso().required(),
16
14
  version: Joi.string().max(10).required(),
17
15
  application: Joi.string().max(MAX_30).required(),
18
16
  service: Joi.string().max(MAX_30).required(),
19
17
  eventData: Joi.object({
20
- accounts: Joi.object({
21
- sbi: accountId,
22
- }).default({}),
23
- status: Joi.string().max(MAX_120).allow(""),
24
- details: Joi.object().default({}),
25
- }).required(),
18
+ eventType: Joi.string()
19
+ .valid(AGREEMENT_CREATED, AGREEMENT_STATUS_CHANGED)
20
+ .required(),
21
+ })
22
+ .when(Joi.object({ eventType: AGREEMENT_CREATED }).unknown(), {
23
+ then: Joi.object({
24
+ agreementId: Joi.string().required(),
25
+ agreementType: Joi.string().required(),
26
+ agreementStatus: Joi.string().required(),
27
+ agreementStartDate: Joi.date().iso().optional(),
28
+ agreementEndDate: Joi.date().iso().optional(),
29
+ agreementValue: Joi.number().optional(),
30
+ sbi: accountId.required(),
31
+ options: Joi.array()
32
+ .items(
33
+ Joi.object({
34
+ parcelReference: Joi.string().allow("").required(),
35
+ parcelSizeUnderAgreement: Joi.number().optional(),
36
+ optionCode: Joi.string().required(),
37
+ optionYear: Joi.number().integer().optional(),
38
+ optionStartDate: Joi.date().iso().required(),
39
+ optionEndDate: Joi.date().iso().required(),
40
+ optionQuantity: Joi.number().required(),
41
+ optionValue: Joi.number().required(),
42
+ }),
43
+ )
44
+ .required(),
45
+ }),
46
+ })
47
+ .when(Joi.object({ eventType: AGREEMENT_STATUS_CHANGED }).unknown(), {
48
+ then: Joi.object({
49
+ agreementId: Joi.string().required(),
50
+ agreementStatus: Joi.string().required(),
51
+ statusDate: Joi.date().iso().required(),
52
+ agreementStartDate: Joi.date().iso().optional(),
53
+ agreementEndDate: Joi.date().iso().optional(),
54
+ agreementValue: Joi.number().optional(),
55
+ userId: Joi.string().optional(),
56
+ }),
57
+ })
58
+ .unknown()
59
+ .required(),
26
60
  }).required();
27
61
 
28
62
  export const configSchema = Joi.object({
@@ -1,4 +1,5 @@
1
1
  import { validateReportingEvent } from "./validate.js";
2
+ import { AGREEMENT_CREATED, AGREEMENT_STATUS_CHANGED } from "./constants.js";
2
3
 
3
4
  const validBase = {
4
5
  correlationId: "abc-123",
@@ -9,9 +10,11 @@ const validBase = {
9
10
  };
10
11
 
11
12
  const eventDataPayload = {
12
- accounts: {
13
- sbi: "abc123",
14
- },
13
+ eventType: AGREEMENT_STATUS_CHANGED,
14
+ agreementId: "WMP123456789",
15
+ agreementStatus: "ACCEPTED",
16
+ statusDate: "2025-12-01T12:51:41.381Z",
17
+ sbi: "abc123",
15
18
  };
16
19
 
17
20
  describe("validateReportingEvent", () => {
@@ -25,24 +28,27 @@ describe("validateReportingEvent", () => {
25
28
  expect(result.value).toBeDefined();
26
29
  });
27
30
 
28
- test("accepts optional status and details fields", () => {
31
+ test("accepts optional agreementStatus and details fields", () => {
29
32
  const result = validateReportingEvent({
30
33
  ...validBase,
31
34
  eventData: {
32
- status: "created",
35
+ eventType: AGREEMENT_STATUS_CHANGED,
36
+ agreementId: "WMP123456789",
37
+ agreementStatus: "created",
38
+ statusDate: "2025-12-01T12:51:41.381Z",
33
39
  details: { agreementId: "agreement_456" },
34
40
  },
35
41
  });
36
42
  expect(result.valid).toBe(true);
37
43
  });
38
44
 
39
- test("normalises the validated value (applies joi defaults)", () => {
45
+ test("normalises the validated value (converts datetime to Date object)", () => {
40
46
  const result = validateReportingEvent({
41
47
  ...validBase,
42
48
  eventData: eventDataPayload,
43
49
  });
44
50
  expect(result.valid).toBe(true);
45
- expect(result.value.eventData.details).toEqual({});
51
+ expect(result.value.datetime).toBeInstanceOf(Date);
46
52
  });
47
53
  });
48
54
 
@@ -99,10 +105,212 @@ describe("validateReportingEvent", () => {
99
105
  expect(result.errors.some((e) => e.includes("version"))).toBe(true);
100
106
  });
101
107
 
108
+ test("returns valid:false when eventType is invalid", () => {
109
+ const result = validateReportingEvent({
110
+ ...validBase,
111
+ eventData: {
112
+ ...eventDataPayload,
113
+ eventType: "INVALID_EVENT_TYPE",
114
+ },
115
+ });
116
+ expect(result.valid).toBe(false);
117
+ expect(result.errors).toContain(
118
+ '"eventData.eventType" must be one of [AGREEMENT_CREATED, AGREEMENT_STATUS_CHANGED]',
119
+ );
120
+ });
121
+
102
122
  test("returns multiple errors with abortEarly:false", () => {
103
123
  const result = validateReportingEvent({ eventData: eventDataPayload });
104
124
  expect(result.valid).toBe(false);
105
125
  expect(result.errors.length).toBeGreaterThan(1);
106
126
  });
107
127
  });
128
+
129
+ describe("Agreement created events", () => {
130
+ test("accepts a valid Agreement created event", () => {
131
+ const result = validateReportingEvent({
132
+ ...validBase,
133
+ eventData: {
134
+ eventType: AGREEMENT_CREATED,
135
+ agreementId: "WMP123456789",
136
+ agreementType: "WOODLAND",
137
+ agreementStatus: "ACCEPTED",
138
+ agreementStartDate: "2026-09-01",
139
+ agreementEndDate: "2029-08-31",
140
+ agreementValue: 1575.0,
141
+ sbi: "200000001",
142
+ options: [],
143
+ },
144
+ });
145
+ expect(result.valid).toBe(true);
146
+ });
147
+
148
+ test("accepts Agreement created event with options", () => {
149
+ const result = validateReportingEvent({
150
+ ...validBase,
151
+ eventData: {
152
+ eventType: AGREEMENT_CREATED,
153
+ agreementId: "WMP123456789",
154
+ agreementType: "WOODLAND",
155
+ agreementStatus: "ACCEPTED",
156
+ sbi: "200000001",
157
+ options: [
158
+ {
159
+ parcelReference: "SD8545-9935",
160
+ parcelSizeUnderAgreement: 15.75,
161
+ optionCode: "WMP1",
162
+ optionYear: 1,
163
+ optionStartDate: "2026-09-01",
164
+ optionEndDate: "2029-08-31",
165
+ optionQuantity: 15.75,
166
+ optionValue: 1575.0,
167
+ },
168
+ ],
169
+ },
170
+ });
171
+ expect(result.valid).toBe(true);
172
+ });
173
+
174
+ test("accepts Agreement created event without optional fields", () => {
175
+ const result = validateReportingEvent({
176
+ ...validBase,
177
+ eventData: {
178
+ eventType: AGREEMENT_CREATED,
179
+ agreementId: "WMP123456789",
180
+ agreementType: "WOODLAND",
181
+ agreementStatus: "ACCEPTED",
182
+ sbi: "200000001",
183
+ options: [],
184
+ },
185
+ });
186
+ expect(result.valid).toBe(true);
187
+ });
188
+
189
+ test("accepts Agreement created event with minimal options", () => {
190
+ const result = validateReportingEvent({
191
+ ...validBase,
192
+ eventData: {
193
+ eventType: AGREEMENT_CREATED,
194
+ agreementId: "WMP123456789",
195
+ agreementType: "WOODLAND",
196
+ agreementStatus: "ACCEPTED",
197
+ sbi: "200000001",
198
+ options: [
199
+ {
200
+ parcelReference: "SD8545-9935",
201
+ optionCode: "WMP1",
202
+ optionStartDate: "2026-09-01",
203
+ optionEndDate: "2029-08-31",
204
+ optionQuantity: 15.75,
205
+ optionValue: 1575.0,
206
+ },
207
+ ],
208
+ },
209
+ });
210
+ expect(result.valid).toBe(true);
211
+ });
212
+
213
+ test("rejects Agreement created event if required fields are missing", () => {
214
+ const result = validateReportingEvent({
215
+ ...validBase,
216
+ eventData: {
217
+ eventType: AGREEMENT_CREATED,
218
+ agreementId: "WMP123456789",
219
+ },
220
+ });
221
+ expect(result.valid).toBe(false);
222
+ expect(result.errors).toContain('"eventData.agreementType" is required');
223
+ expect(result.errors).toContain(
224
+ '"eventData.agreementStatus" is required',
225
+ );
226
+ expect(result.errors).toContain('"eventData.sbi" is required');
227
+ expect(result.errors).toContain('"eventData.options" is required');
228
+ });
229
+
230
+ test("rejects Agreement created event if options contain invalid data", () => {
231
+ const result = validateReportingEvent({
232
+ ...validBase,
233
+ eventData: {
234
+ eventType: AGREEMENT_CREATED,
235
+ agreementId: "WMP123456789",
236
+ agreementType: "WOODLAND",
237
+ agreementStatus: "ACCEPTED",
238
+ sbi: "200000001",
239
+ options: [
240
+ {
241
+ agreementId: "WMP123456789",
242
+ // missing mandatory option fields
243
+ },
244
+ ],
245
+ },
246
+ });
247
+ expect(result.valid).toBe(false);
248
+ expect(result.errors).toContain(
249
+ '"eventData.options[0].parcelReference" is required',
250
+ );
251
+ });
252
+ });
253
+
254
+ describe("Agreement status changed events", () => {
255
+ test("accepts a valid Agreement status changed event with all fields", () => {
256
+ const result = validateReportingEvent({
257
+ ...validBase,
258
+ eventData: {
259
+ eventType: AGREEMENT_STATUS_CHANGED,
260
+ agreementId: "WMP123456789",
261
+ agreementStatus: "ACCEPTED",
262
+ agreementStartDate: "2026-09-01",
263
+ agreementEndDate: "2029-08-31",
264
+ statusDate: "2026-09-02T10:00:00Z",
265
+ agreementValue: 1575.0,
266
+ },
267
+ });
268
+ expect(result.valid).toBe(true);
269
+ });
270
+
271
+ test("accepts a valid Agreement status changed event with all fields including userId", () => {
272
+ const result = validateReportingEvent({
273
+ ...validBase,
274
+ eventData: {
275
+ eventType: AGREEMENT_STATUS_CHANGED,
276
+ agreementId: "WMP123456789",
277
+ agreementStatus: "ACCEPTED",
278
+ agreementStartDate: "2026-09-01",
279
+ agreementEndDate: "2029-08-31",
280
+ statusDate: "2026-09-02T10:00:00Z",
281
+ agreementValue: 1575.0,
282
+ userId: "user-123",
283
+ },
284
+ });
285
+ expect(result.valid).toBe(true);
286
+ });
287
+
288
+ test("accepts a valid Agreement status changed event with only mandatory fields", () => {
289
+ const result = validateReportingEvent({
290
+ ...validBase,
291
+ eventData: {
292
+ eventType: AGREEMENT_STATUS_CHANGED,
293
+ agreementId: "WMP123456789",
294
+ agreementStatus: "ACCEPTED",
295
+ statusDate: "2026-09-02T10:00:00Z",
296
+ },
297
+ });
298
+ expect(result.valid).toBe(true);
299
+ });
300
+
301
+ test("rejects Agreement status changed event if required fields are missing", () => {
302
+ const result = validateReportingEvent({
303
+ ...validBase,
304
+ eventData: {
305
+ eventType: AGREEMENT_STATUS_CHANGED,
306
+ agreementId: "WMP123456789",
307
+ },
308
+ });
309
+ expect(result.valid).toBe(false);
310
+ expect(result.errors).toContain(
311
+ '"eventData.agreementStatus" is required',
312
+ );
313
+ expect(result.errors).toContain('"eventData.statusDate" is required');
314
+ });
315
+ });
108
316
  });