From 1f64263bb842b9225c70382561df99971c03edc5 Mon Sep 17 00:00:00 2001 From: IsaiahSama Date: Thu, 15 Jan 2026 16:47:01 -0400 Subject: [PATCH 01/49] Updated missing validation --- schemas/sell-goods-services-beach-park.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/schemas/sell-goods-services-beach-park.json b/schemas/sell-goods-services-beach-park.json index 4081786..c988130 100644 --- a/schemas/sell-goods-services-beach-park.json +++ b/schemas/sell-goods-services-beach-park.json @@ -60,13 +60,18 @@ "message": "Date of birth is required and must be in YYYY-MM-DD format" } }, + { + "name": "nationality", + "type": "string", + "label": "Nationality", + "required": true + }, { "name": "idNumber", "type": "string", "label": "National Identification (ID) Number", "required": false, "validations": { - "min": 2, "message": "ID Number must be at least 2 characters" } }, From ab42bd96d1f51052cfce290c0b4e1e0da329b554 Mon Sep 17 00:00:00 2001 From: Shannon Clarke Date: Fri, 16 Jan 2026 05:21:39 -0400 Subject: [PATCH 02/49] Fix (forms): update schema for "redirect business mail" api (#55) * fix: update schema to match figma * fix: update form schema and validations for empty strings --- schemas/post-office-redirection-business.json | 166 +++++++++--------- src/validation/schema-builder.service.ts | 35 ++-- 2 files changed, 98 insertions(+), 103 deletions(-) diff --git a/schemas/post-office-redirection-business.json b/schemas/post-office-redirection-business.json index 7f0b3e4..bc81c82 100644 --- a/schemas/post-office-redirection-business.json +++ b/schemas/post-office-redirection-business.json @@ -4,141 +4,141 @@ "description": "Change where your mail is sent (business)", "fields": [ { - "name": "applicant", + "name": "businessName", + "type": "string", + "label": "Business name", + "required": true, + "validations": { + "min": 2, + "message": "Business name is required" + } + }, + { + "name": "currentAddress", "type": "object", "required": true, "fields": [ { - "name": "title", - "type": "string", - "label": "Title", - "required": true, - "validations": { - "regex": "^(mr|ms|mrs)$", - "message": "Must select a valid title" - } - }, - { - "name": "firstName", + "name": "addressLine1", "type": "string", - "label": "First name", + "label": "Address line 1", "required": true, "validations": { - "min": 2, - "max": 100, - "message": "First name is required" + "min": 5, + "max": 200, + "message": "Address must be at least 5 characters" } }, { - "name": "lastName", + "name": "addressLine2", "type": "string", - "label": "Last name", - "required": true, - "validations": { - "min": 2, - "max": 100, - "message": "Last name is required" - } - }, - { - "name": "dateOfBirth", - "type": "date", - "label": "Date of birth", + "label": "Address line 2", "required": false, "validations": { - "regex": "^\\d{4}-\\d{2}-\\d{2}$", - "message": "Date of birth is required and must be in YYYY-MM-DD format" + "max": 200 } }, { - "name": "idNumber", + "name": "parish", "type": "string", - "label": "National Identification (ID) Number", - "required": false, + "label": "Parish", + "required": true, "validations": { - "min": 2, - "message": "ID Number must be at least 2 characters" + "regex": "^(christ-church|st-andrew|st-george|st-james|st-john|st-joseph|st-lucy|st-michael|st-peter|st-philip|st-thomas)$", + "message": "Must select a valid parish" } }, { - "name": "passportNumber", + "name": "postcode", "type": "string", - "label": "Passport Number", + "label": "Postcode", "required": false, "validations": { - "message": "Passport number must be at least 6 characters" + "regex": "^BB\\d{5}$", + "message": "Enter a valid postal code (e.g., BB17004)" } - }, - { - "name": "email", - "type": "email", - "label": "Email Address", - "required": true - }, - { - "name": "telephoneNumber", - "type": "string", - "label": "Telephone Number", - "required": true } ] }, { - "name": "oldBusinessAddress", + "name": "applicant", "type": "object", "required": true, "fields": [ { - "name": "addressLine1", + "name": "title", "type": "string", - "label": "Address Line 1", + "label": "Title", "required": true, "validations": { - "min": 5, - "max": 200, - "message": "Address must be at least 5 characters" + "regex": "^(mr|ms|mrs)$", + "message": "Must select a valid title" } }, { - "name": "addressLine2", + "name": "firstName", "type": "string", - "label": "Address Line 2", - "required": false, + "label": "First name", + "required": true, "validations": { - "max": 200 + "min": 2, + "max": 100, + "message": "First name is required" } }, { - "name": "parish", + "name": "middleName", "type": "string", - "label": "Parish", - "required": true, + "label": "Middle name", + "required": false, "validations": { - "regex": "^(christ-church|st-andrew|st-george|st-james|st-john|st-joseph|st-lucy|st-michael|st-peter|st-philip|st-thomas)$", - "message": "Must select a valid parish" + "max": 100, + "message": "Middle name is required" } }, { - "name": "postalCode", + "name": "lastName", "type": "string", - "label": "Postal Code", - "required": false, + "label": "Last name", + "required": true, "validations": { - "regex": "^BB\\d{5}$", - "message": "Enter a valid postal code (e.g., BB17004)" + "min": 2, + "max": 100, + "message": "Last name is required" } + }, + { + "name": "email", + "type": "email", + "label": "Email Address", + "required": true + }, + { + "name": "telephoneNumber", + "type": "string", + "label": "Telephone Number", + "required": true } ] }, { - "name": "newBusinessAddress", + "name": "permissionDetails", + "type": "string", + "label": "Tell us what permission you have to act for this business", + "required": true, + "validations": { + "message": "Permission details required" + } + }, + { + "name": "newAddress", "type": "object", "required": true, "fields": [ { "name": "addressLine1", "type": "string", - "label": "Address Line 1", + "label": "Address line 1", "required": true, "validations": { "min": 5, @@ -149,7 +149,7 @@ { "name": "addressLine2", "type": "string", - "label": "Address Line 2", + "label": "Address line 2", "required": false, "validations": { "max": 200 @@ -166,9 +166,9 @@ } }, { - "name": "postalCode", + "name": "postcode", "type": "string", - "label": "Postal Code", + "label": "Postcode", "required": false, "validations": { "regex": "^BB\\d{5}$", @@ -189,21 +189,13 @@ "name": "redirectionStartDate", "type": "date", "label": "Redirection Start Date", - "required": false, - "validations": { - "regex": "^\\d{4}-\\d{2}-\\d{2}$", - "message": "Redirection Start Date is required and must be in YYYY-MM-DD format" - } + "required": false }, { "name": "redirectionEndDate", "type": "date", "label": "Redirection End Date", - "required": false, - "validations": { - "regex": "^\\d{4}-\\d{2}-\\d{2}$", - "message": "Redirection End Date is required and must be in YYYY-MM-DD format" - } + "required": false } ] }, @@ -222,7 +214,7 @@ "type": "email", "config": { "to": "{{db:post-office-redirection-business:admin_email}}", - "subject": "New Request to Redirect Mail for a Business - {{formData.applicant.firstName}} {{formData.applicant.lastName}}", + "subject": "New Request to Redirect my business mail - {{formData.applicant.firstName}} {{formData.applicant.lastName}}", "template": "post-office-redirection-notice" } } diff --git a/src/validation/schema-builder.service.ts b/src/validation/schema-builder.service.ts index f420541..6659dce 100644 --- a/src/validation/schema-builder.service.ts +++ b/src/validation/schema-builder.service.ts @@ -70,7 +70,13 @@ export class SchemaBuilderService { // Build base schema based on field type switch (field.type) { case 'string': - schema = z.coerce.string(); + // For required strings, use z.string().min(1) to reject empty strings + // For optional strings, use z.coerce.string() but allow empty + if (field.required) { + schema = z.string().min(1, 'This field is required'); + } else { + schema = z.coerce.string(); + } break; case 'email': schema = z.string().email('Invalid email format'); @@ -138,21 +144,15 @@ export class SchemaBuilderService { ) { if (validations.min !== undefined) { const minLength = validations.min; - if (!required) { - // For optional fields, allow empty strings or strings that meet min length - schema = schema.refine( - (val: string) => !val || val.length >= minLength, - { - message: - validations.message || `Minimum length is ${minLength}`, - }, - ); - } else { - schema = schema.min( - minLength, - validations.message || `Minimum length is ${minLength}`, - ); - } + // Always enforce minimum length (use max(1, minLength) for required fields) + const effectiveMin = required ? Math.max(1, minLength) : minLength; + schema = schema.min( + effectiveMin, + validations.message || `Minimum length is ${effectiveMin}`, + ); + } else if (required) { + // If no min specified but field is required, enforce min length of 1 + schema = schema.min(1, validations.message || 'This field is required'); } if (validations.max !== undefined) { schema = schema.max( @@ -160,6 +160,9 @@ export class SchemaBuilderService { validations.message || `Maximum length is ${validations.max}`, ); } + } else if (fieldType === 'string' && required) { + // If no validations but field is required, enforce non-empty string + schema = schema.min(1, 'This field is required'); } // Min/Max for numbers From ff49d14f3683e73a2fc99d27e3c4548ea8d4d24d Mon Sep 17 00:00:00 2001 From: Shannon Clarke Date: Fri, 16 Jan 2026 05:42:40 -0400 Subject: [PATCH 03/49] fix: telephone validation in sell goods or services form (#53) --- schemas/sell-goods-services-beach-park.json | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/schemas/sell-goods-services-beach-park.json b/schemas/sell-goods-services-beach-park.json index 4081786..de73259 100644 --- a/schemas/sell-goods-services-beach-park.json +++ b/schemas/sell-goods-services-beach-park.json @@ -89,11 +89,7 @@ "name": "telephoneNumber", "type": "string", "label": "Telephone Number", - "required": true, - "validations": { - "regex": "^\\+?[0-9]{10,15}$", - "message": "Telephone number must be 10-15 digits" - } + "required": true }, { "name": "addressLine1", From c6ce26533f066194838d89e751d882c89a2029a6 Mon Sep 17 00:00:00 2001 From: Shannon Clarke Date: Fri, 16 Jan 2026 05:43:01 -0400 Subject: [PATCH 04/49] fix (forms): change api for "Tell Post Office someone died" form (#54) * fix: update schema for deceased form to match figma * fix: gracefully fail if unable to connect to smtp server --- schemas/post-office-redirection-deceased.json | 152 ++++++++----- src/email/email.service.ts | 6 +- .../post-office-redirection-notice.hbs | 210 +++++++++++------- .../implementations/email.processor.ts | 41 ++-- 4 files changed, 248 insertions(+), 161 deletions(-) diff --git a/schemas/post-office-redirection-deceased.json b/schemas/post-office-redirection-deceased.json index c8e9c8d..bf28cc4 100644 --- a/schemas/post-office-redirection-deceased.json +++ b/schemas/post-office-redirection-deceased.json @@ -4,7 +4,7 @@ "description": "Change where your mail is sent (deceased)", "fields": [ { - "name": "applicant", + "name": "deceased", "type": "object", "required": true, "fields": [ @@ -29,6 +29,12 @@ "message": "First name is required" } }, + { + "name": "middleName", + "type": "string", + "label": "Middle name", + "required": false + }, { "name": "lastName", "type": "string", @@ -41,45 +47,14 @@ } }, { - "name": "dateOfBirth", + "name": "dateOfDeath", "type": "date", - "label": "Date of birth", + "label": "Date of death", "required": false, "validations": { "regex": "^\\d{4}-\\d{2}-\\d{2}$", - "message": "Date of birth is required and must be in YYYY-MM-DD format" - } - }, - { - "name": "idNumber", - "type": "string", - "label": "National Identification (ID) Number", - "required": false, - "validations": { - "min": 2, - "message": "ID Number must be at least 2 characters" - } - }, - { - "name": "passportNumber", - "type": "string", - "label": "Passport Number", - "required": false, - "validations": { - "message": "Passport number must be at least 6 characters" + "message": "Date of death is required and must be in YYYY-MM-DD format" } - }, - { - "name": "email", - "type": "email", - "label": "Email Address", - "required": true - }, - { - "name": "telephoneNumber", - "type": "string", - "label": "Telephone Number", - "required": true } ] }, @@ -91,7 +66,7 @@ { "name": "addressLine1", "type": "string", - "label": "Address Line 1", + "label": "Address line 1", "required": true, "validations": { "min": 5, @@ -102,7 +77,7 @@ { "name": "addressLine2", "type": "string", - "label": "Address Line 2", + "label": "Address line 2", "required": false, "validations": { "max": 200 @@ -119,9 +94,9 @@ } }, { - "name": "postalCode", + "name": "postcode", "type": "string", - "label": "Postal Code", + "label": "Postcode", "required": false, "validations": { "regex": "^BB\\d{5}$", @@ -130,6 +105,77 @@ } ] }, + { + "name": "applicant", + "type": "object", + "required": true, + "fields": [ + { + "name": "title", + "type": "string", + "label": "Title", + "required": true, + "validations": { + "regex": "^(mr|ms|mrs)$", + "message": "Must select a valid title" + } + }, + { + "name": "firstName", + "type": "string", + "label": "First name", + "required": true, + "validations": { + "min": 1, + "max": 100, + "message": "First name is required" + } + }, + { + "name": "relationshipToDeceased", + "type": "string", + "label": "What is your relationship to the person?", + "required": true, + "validations": { + "min": 1, + "max": 100, + "message": "Relationship to deceased is required" + } + }, + { + "name": "lastName", + "type": "string", + "label": "Last name", + "required": true, + "validations": { + "min": 1, + "max": 100, + "message": "Last name is required" + } + }, + { + "name": "email", + "type": "email", + "label": "Email Address", + "required": true + }, + { + "name": "telephoneNumber", + "type": "string", + "label": "Telephone Number", + "required": true + } + ] + }, + { + "name": "permissionDetails", + "type": "string", + "label": "Tell us what permission you have to act on behalf of the estate", + "required": true, + "validations": { + "message": "Permission details required" + } + }, { "name": "newAddress", "type": "object", @@ -138,7 +184,7 @@ { "name": "addressLine1", "type": "string", - "label": "Address Line 1", + "label": "Address line 1", "required": true, "validations": { "min": 5, @@ -149,7 +195,7 @@ { "name": "addressLine2", "type": "string", - "label": "Address Line 2", + "label": "Address line 2", "required": false, "validations": { "max": 200 @@ -166,9 +212,9 @@ } }, { - "name": "postalCode", + "name": "postcode", "type": "string", - "label": "Postal Code", + "label": "Postcode", "required": false, "validations": { "regex": "^BB\\d{5}$", @@ -176,9 +222,9 @@ } }, { - "name": "isMovingPermanent", + "name": "isRedirectPermanent", "type": "string", - "label": "Are you moving permanently?", + "label": "Are you redirecting their mail permanently?", "required": true, "validations": { "regex": "^(yes|no)$", @@ -187,23 +233,15 @@ }, { "name": "redirectionStartDate", - "type": "date", + "type": "string", "label": "Redirection Start Date", - "required": false, - "validations": { - "regex": "^\\d{4}-\\d{2}-\\d{2}$", - "message": "Redirection Start Date is required and must be in YYYY-MM-DD format" - } + "required": false }, { "name": "redirectionEndDate", - "type": "date", + "type": "string", "label": "Redirection End Date", - "required": false, - "validations": { - "regex": "^\\d{4}-\\d{2}-\\d{2}$", - "message": "Redirection End Date is required and must be in YYYY-MM-DD format" - } + "required": false } ] }, diff --git a/src/email/email.service.ts b/src/email/email.service.ts index 4571c9d..1bdcbbc 100644 --- a/src/email/email.service.ts +++ b/src/email/email.service.ts @@ -198,8 +198,10 @@ export class EmailService { `Email sent successfully to ${validToAddresses.join(', ')}`, ); } catch (error) { - this.logger.error(`Failed to send email: ${error.message}`, error.stack); - throw error; + this.logger.warn( + `Failed to send email (continuing silently): ${error.message}`, + ); + // Silently fail - do not throw the error } } diff --git a/src/email/templates/post-office-redirection-notice.hbs b/src/email/templates/post-office-redirection-notice.hbs index 5346102..5ba9bd9 100644 --- a/src/email/templates/post-office-redirection-notice.hbs +++ b/src/email/templates/post-office-redirection-notice.hbs @@ -18,136 +18,176 @@ border-radius: 5px; } .old-address { background-color: #fed7d7; } .new-address { background-color: #c6f6d5; } .footer { margin-top: 30px; padding: 20px; background-color: #edf2f7; border-radius: 5px; text-align: - center; font-size: 14px; color: #718096; } + center; font-size: 14px; color: #718096; } .document-list { list-style: + none; padding-left: 0; } .document-list li { padding: 8px; + background-color: #edf2f7; margin-bottom: 8px; border-radius: 3px; + word-break: break-all; }
-

📮 Post Office Redirection Notice Request

+

📮 Post Office Redirection for Deceased - Request Submission

-

A new post office redirection notice request has been submitted.

+

A new post office redirection request for a deceased person has been + submitted.

+
-
Personal Information
+
Deceased Person Information
- Name: - {{applicant.title}} - {{applicant.firstName}} - {{applicant.lastName}} + Title: + {{deceased.title}}
- Date of Birth: - {{applicant.dateOfBirth}} + First Name: + {{deceased.firstName}}
-
- ID Number: - {{applicant.idNumber}} -
- {{#if applicant.passportNumber}} + {{#if deceased.middleName}}
- Passport Number: - {{applicant.passportNumber}} + Middle Name: + {{deceased.middleName}}
{{/if}} - {{#if applicant.email}} +
+ Last Name: + {{deceased.lastName}} +
+ {{#if deceased.dateOfDeath}}
- Email: - {{applicant.email}} + Date of Death: + {{deceased.dateOfDeath}}
{{/if}} - {{#if applicant.email}} +
+ + +
+
Old/Previous Address
+
+ Address Line 1: + {{oldAddress.addressLine1}} +
+ {{#if oldAddress.addressLine2}}
- Email: - {{applicant.email}} + Address Line 2: + {{oldAddress.addressLine2}}
{{/if}} - {{#if applicant.telephoneNumber}} +
+ Parish: + {{oldAddress.parish}} +
+ {{#if oldAddress.postcode}}
- Phone Number: - {{applicant.telephoneNumber}} + Postcode: + {{oldAddress.postcode}}
{{/if}}
+
-
Address Redirection Details
-
-
-

Old Address

-
- Address Line 1: - {{oldaddress.addressLine1}} -
- {{#if oldaddress.addressLine2}} -
- Address Line 2: - {{oldaddress.addressLine2}} -
- {{/if}} -
- Parish: - {{oldaddress.parish}} -
- {{#if oldaddress.postalCode}} -
- Postal Code: - {{oldaddress.postalCode}} -
- {{/if}} -
+
Applicant Information
+
+ Title: + {{applicant.title}} +
+
+ First Name: + {{applicant.firstName}} +
+
+ Last Name: + {{applicant.lastName}} +
+
+ Relationship to Deceased: + {{applicant.relationshipToDeceased}} +
+
+ Email Address: + {{applicant.email}} +
+
+ Telephone Number: + {{applicant.telephoneNumber}} +
+
-
-

New Address

-
- Address Line 1: - {{newAddress.addressLine1}} -
- {{#if newAddress.addressLine2}} -
- Address Line 2: - {{newAddress.addressLine2}} -
- {{/if}} -
- Parish: - {{newAddress.parish}} -
- {{#if newAddress.postalCode}} -
- Postal Code: - {{newAddress.postalCode}} -
- {{/if}} -
+ +
+
Permission Details
+
+ Permission to Act on Estate: + {{permissionDetails}}
+
-
House Member Information
+
New Address for Mail Redirection
+
+ Address Line 1: + {{newAddress.addressLine1}} +
+ {{#if newAddress.addressLine2}} +
+ Address Line 2: + {{newAddress.addressLine2}} +
+ {{/if}}
- Member Name: - {{houseMembers.firstName}} - {{houseMembers.lastName}} + Parish: + {{newAddress.parish}}
+ {{#if newAddress.postcode}} +
+ Postcode: + {{newAddress.postcode}} +
+ {{/if}}
- Member ID Number: - {{houseMembers.idNumber}} + Permanent Redirection: + {{newAddress.isRedirectPermanent}}
- {{#if houseMembers.addAnother}} + {{#if newAddress.redirectionStartDate}}
- Add Another Member: - {{houseMembers.addAnother}} + Redirection Start Date: + {{newAddress.redirectionStartDate}} +
+ {{/if}} + {{#if newAddress.redirectionEndDate}} +
+ Redirection End Date: + {{newAddress.redirectionEndDate}}
{{/if}}
+ + {{#if uploadDocumentUrls}} +
+
Uploaded Documents
+ {{#if uploadDocumentUrls.length}} +
    + {{#each uploadDocumentUrls}} +
  • {{this}}
  • + {{/each}} +
+ {{else}} +

No documents uploaded.

+ {{/if}} +
+ {{/if}} +
diff --git a/src/processors/implementations/email.processor.ts b/src/processors/implementations/email.processor.ts index 3f7dcce..a2c2d5f 100644 --- a/src/processors/implementations/email.processor.ts +++ b/src/processors/implementations/email.processor.ts @@ -38,22 +38,29 @@ export class EmailProcessor implements IProcessor { return; } - await this.emailService.sendEmail({ - to, - from, - subject, - template, - html, - text, - data: { - formId: context.formId, - submissionId: context.submissionId, - ...context.data, - }, - }); - - this.logger.log( - `Email sent successfully for submission: ${context.submissionId}`, - ); + try { + await this.emailService.sendEmail({ + to, + from, + subject, + template, + html, + text, + data: { + formId: context.formId, + submissionId: context.submissionId, + ...context.data, + }, + }); + + this.logger.log( + `Email sent successfully for submission: ${context.submissionId}`, + ); + } catch (error) { + this.logger.warn( + `Email processor encountered an error but continuing (email service may be unavailable): ${error.message}`, + ); + // Continue without throwing - email failures should not block form submission + } } } From bd3494ffda84d531c5d8f3aadf488219a4d44d7d Mon Sep 17 00:00:00 2001 From: Akinola Raphael Date: Fri, 16 Jan 2026 14:11:06 +0100 Subject: [PATCH 05/49] chore: update email error logic --- .../implementations/email.processor.ts | 41 ++++++++----------- 1 file changed, 17 insertions(+), 24 deletions(-) diff --git a/src/processors/implementations/email.processor.ts b/src/processors/implementations/email.processor.ts index a2c2d5f..3f7dcce 100644 --- a/src/processors/implementations/email.processor.ts +++ b/src/processors/implementations/email.processor.ts @@ -38,29 +38,22 @@ export class EmailProcessor implements IProcessor { return; } - try { - await this.emailService.sendEmail({ - to, - from, - subject, - template, - html, - text, - data: { - formId: context.formId, - submissionId: context.submissionId, - ...context.data, - }, - }); - - this.logger.log( - `Email sent successfully for submission: ${context.submissionId}`, - ); - } catch (error) { - this.logger.warn( - `Email processor encountered an error but continuing (email service may be unavailable): ${error.message}`, - ); - // Continue without throwing - email failures should not block form submission - } + await this.emailService.sendEmail({ + to, + from, + subject, + template, + html, + text, + data: { + formId: context.formId, + submissionId: context.submissionId, + ...context.data, + }, + }); + + this.logger.log( + `Email sent successfully for submission: ${context.submissionId}`, + ); } } From aa6984f2b02bc878670bd89d63a001f8a9ffbfcd Mon Sep 17 00:00:00 2001 From: Shannon Clarke Date: Fri, 16 Jan 2026 09:47:10 -0400 Subject: [PATCH 06/49] fix: add missing form field to form schema --- .../post-office-redirection-individual.json | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/schemas/post-office-redirection-individual.json b/schemas/post-office-redirection-individual.json index f440c7c..ab5e075 100644 --- a/schemas/post-office-redirection-individual.json +++ b/schemas/post-office-redirection-individual.json @@ -208,14 +208,27 @@ ] }, { - "name": "anyMinorDependents", - "type": "string", - "label": "Are there any minor dependents?", + "name": "dependents", + "type": "object", "required": true, - "validations": { - "regex": "^(yes|no)$", - "message": "Must select an option" - } + "fields": [ + { + "name": "anyMinorDependents", + "type": "string", + "label": "Are there any minor dependents?", + "required": true, + "validations": { + "regex": "^(yes|no)$", + "message": "Must select an option" + } + }, + { + "name": "numberOfMinors", + "type": "string", + "label": "How many minor dependents need their mail sent to the new address?", + "required": false + } + ] }, { "name": "minorDetails", From cff4bc929a4277b825cc6936d9d62993246262eb Mon Sep 17 00:00:00 2001 From: Shannon Clarke Date: Mon, 19 Jan 2026 09:00:01 -0400 Subject: [PATCH 07/49] chore: update schema to use nested values --- schemas/community-sports-programme.json | 439 ++++++++++++++---------- 1 file changed, 255 insertions(+), 184 deletions(-) diff --git a/schemas/community-sports-programme.json b/schemas/community-sports-programme.json index 271bb56..1d90381 100644 --- a/schemas/community-sports-programme.json +++ b/schemas/community-sports-programme.json @@ -4,219 +4,290 @@ "description": "Register for our community sports programme", "fields": [ { - "name": "firstName", - "type": "string", - "label": "First Name", - "required": true, - "validations": { - "min": 2, - "max": 50 - } - }, - { - "name": "lastName", - "type": "string", - "label": "Last Name", + "name": "applicant", + "type": "object", "required": true, - "validations": { - "min": 2, - "max": 50 - } + "fields": [ + { + "name": "firstName", + "type": "string", + "label": "First name", + "required": true, + "validations": { + "min": 1, + "max": 100, + "message": "First name is required" + } + }, + { + "name": "lastName", + "type": "string", + "label": "Last name", + "required": true, + "validations": { + "min": 1, + "max": 100, + "message": "Last name is required" + } + }, + { + "name": "dateOfBirth", + "type": "date", + "label": "Date of birth", + "required": false, + "validations": { + "regex": "^\\d{4}-\\d{2}-\\d{2}$", + "message": "Date of birth is required and must be in YYYY-MM-DD format" + } + }, + { + "name": "sex", + "type": "string", + "label": "Sex", + "required": true, + "validations": { + "regex": "^(male|female)$", + "message": "Must select a valid sex" + } + } + ] }, { - "name": "dateOfBirth", - "type": "date", - "label": "Date of Birth", + "name": "discipline", + "type": "object", "required": true, - "validations": { - "regex": "^\\d{4}-\\d{2}-\\d{2}$", - "message": "Date must be in YYYY-MM-DD format" - } - }, - { - "name": "gender", - "type": "string", - "label": "Gender", - "required": true - }, - { - "name": "disciplineOfInterest", - "type": "string", - "label": "Discipline of Interest", - "required": true - }, - { - "name": "disciplineExperience", - "type": "string", - "label": "Do you have experience in this discipline?", - "required": true - }, - { - "name": "experienceLevel", - "type": "string", - "label": "Experience Level", - "required": false - }, - { - "name": "otherExperienceLevel", - "type": "string", - "label": "Other Experience Level", - "required": false - }, - { - "name": "yearsOfExperience", - "type": "number", - "label": "Years of Experience", - "required": false, - "validations": { - "min": 0, - "max": 50 - } - }, - { - "name": "employmentStatus", - "type": "string", - "label": "Employment Status", - "required": true + "fields": [ + { + "name": "areaOfInterest", + "type": "string", + "label": "Discipline of Interest", + "required": true + }, + { + "name": "hasExperience", + "type": "string", + "label": "Do you have experience in this discipline?", + "required": true, + "validations": { + "regex": "^(yes|no)$", + "message": "Must select a valid option" + } + } + ] }, { - "name": "institutionName", - "type": "string", - "label": "Institution Name", - "required": false, - "validations": { - "max": 100 - } + "name": "experience", + "type": "object", + "fields": [ + { + "name": "levelOfExperience", + "type": "string", + "label": "Experience Level", + "required": false + }, + { + "name": "otherExperience", + "type": "string", + "label": "Other Experience Level", + "required": false + }, + { + "name": "yearsOfExperience", + "type": "number", + "label": "Years of Experience", + "required": false, + "validations": { + "min": 0, + "max": 50 + } + } + ] }, { - "name": "employerName", - "type": "string", - "label": "Employer Name", - "required": false, - "validations": { - "max": 100 - } + "name": "employment", + "type": "object", + "fields": [ + { + "name": "status", + "type": "string", + "label": "Employment Status", + "required": true + }, + { + "name": "institutionName", + "type": "string", + "label": "Institution Name", + "required": false, + "validations": { + "max": 100 + } + }, + { + "name": "companyName", + "type": "string", + "label": "Company or organisation name is require", + "required": false, + "validations": { + "max": 100 + } + }, + { + "name": "otherDetails", + "type": "string", + "label": "Other Employment Details", + "required": false, + "validations": { + "max": 200 + } + } + ] }, { - "name": "otherEmploymentDetails", + "name": "belongsToOrganisations", "type": "string", - "label": "Other Employment Details", - "required": false, + "label": "Do you belong to any organisations?", + "required": true, "validations": { - "max": 200 + "regex": "^(yes|no)$", + "message": "Must select an option" } }, { - "name": "belongsToOrganizations", - "type": "string", - "label": "Do you belong to any sports organizations?", - "required": true - }, - { - "name": "organizationNames", + "name": "organizationDetails", "type": "array", - "label": "Organization Names", + "label": "Organization Details", "required": false, "items": { "type": "object", "properties": { - "value": { + "organizationName": { + "type": "string" + }, + "hasSignificantPosition": { "type": "string" } } } }, { - "name": "addressLine1", - "type": "string", - "label": "Address Line 1", - "required": true, - "validations": { - "max": 100 - } - }, - { - "name": "addressLine2", - "type": "string", - "label": "Address Line 2", - "required": false, - "validations": { - "max": 100 - } - }, - { - "name": "email", - "type": "string", - "label": "Email address", - "required": true - }, - { - "name": "parish", - "type": "string", - "label": "Parish", - "required": true - }, - { - "name": "telephoneNumber", - "type": "string", - "label": "Telephone Number", - "required": true - }, - { - "name": "emergencyFirstName", - "type": "string", - "label": "Emergency Contact First Name", - "required": true, - "validations": { - "min": 2, - "max": 50 - } - }, - { - "name": "emergencyLastName", - "type": "string", - "label": "Emergency Contact Last Name", + "name": "contact", + "type": "object", + "label": "Contact Information", "required": true, - "validations": { - "min": 2, - "max": 50 - } - }, - { - "name": "emergencyRelationship", - "type": "string", - "label": "Relationship to Emergency Contact", - "required": true + "fields": [ + { + "name": "addressLine1", + "type": "string", + "label": "Address Line 1", + "required": true, + "validations": { + "max": 100 + } + }, + { + "name": "addressLine2", + "type": "string", + "label": "Address Line 2", + "required": false, + "validations": { + "max": 100 + } + }, + { + "name": "parish", + "type": "string", + "label": "Parish", + "required": true + }, + { + "name": "email", + "type": "email", + "label": "Email Address", + "required": true + }, + { + "name": "telephoneNumber", + "type": "string", + "label": "Telephone Number", + "required": true + } + ] }, { - "name": "emergencyAddressLine1", - "type": "string", - "label": "Emergency Contact Address Line 1", + "name": "emergency", + "type": "object", + "label": "Contact Information", "required": true, - "validations": { - "max": 100 - } - }, - { - "name": "emergencyAddressLine2", - "type": "string", - "label": "Emergency Contact Address Line 2", - "required": false, - "validations": { - "max": 100 - } - }, - { - "name": "emergencyParish", - "type": "string", - "label": "Emergency Contact Parish", - "required": true - }, - { - "name": "emergencyTelephoneNumber", - "type": "string", - "label": "Emergency Contact Telephone Number", - "required": true + "fields": [ + { + "name": "firstName", + "type": "string", + "label": "First name", + "required": true, + "validations": { + "min": 1, + "max": 100, + "message": "First name is required" + } + }, + { + "name": "lastName", + "type": "string", + "label": "Last name", + "required": true, + "validations": { + "min": 1, + "max": 100, + "message": "Last name is required" + } + }, + { + "name": "relationship", + "type": "string", + "label": "Relationship", + "required": false, + "validations": { + "max": 100, + "message": "Relationship is required" + } + }, + { + "name": "addressLine1", + "type": "string", + "label": "Address line 1", + "required": true, + "validations": { + "max": 100 + } + }, + { + "name": "addressLine2", + "type": "string", + "label": "Address line 2", + "required": false, + "validations": { + "max": 100 + } + }, + { + "name": "parish", + "type": "string", + "label": "Parish", + "required": true + }, + { + "name": "email", + "type": "email", + "label": "Email Address", + "required": true + }, + { + "name": "telephoneNumber", + "type": "string", + "label": "Telephone Number", + "required": true + } + ] } ], "processors": [ @@ -224,7 +295,7 @@ "type": "email", "config": { "to": "{{db:community-sports-registration:admin_email}}", - "subject": "New Community Sports Programme Registration - {{formData.firstName}} {{formData.lastName}}", + "subject": "New Community Sports Programme Registration - {{formData.applicant.firstName}} {{formData.applicant.lastName}}", "template": "community-sports-registration" } } From 8aee1c05824127cbe3f521e1a49fa41ec3e68817 Mon Sep 17 00:00:00 2001 From: Shannon Clarke Date: Mon, 19 Jan 2026 11:22:31 -0400 Subject: [PATCH 08/49] chore: add missing contact fields for birth certificate form schema --- schemas/get-birth-certificate.json | 1028 ++++++++++++++-------------- 1 file changed, 516 insertions(+), 512 deletions(-) diff --git a/schemas/get-birth-certificate.json b/schemas/get-birth-certificate.json index 86d4da7..90b4a78 100644 --- a/schemas/get-birth-certificate.json +++ b/schemas/get-birth-certificate.json @@ -1,514 +1,518 @@ { - "id": "get-birth-certificate", - "name": "Get Birth Certificate", - "description": "Apply for a copy of your birth certificate or someone else's birth certificate", - "fields": [ - { - "name": "applicant", - "type": "object", - "required": true, - "fields": [ - { - "name": "title", - "type": "string", - "label": "Title", - "required": true, - "validations": { - "regex": "^(mr|ms|mrs)$", - "message": "Must select a valid title" - } - }, - { - "name": "firstName", - "type": "string", - "label": "First name", - "required": true, - "validations": { - "min": 1, - "max": 100, - "message": "First name is required" - } - }, - { - "name": "middleName", - "type": "string", - "label": "Middle name", - "required": false, - "validations": { - "max": 100 - } - }, - { - "name": "lastName", - "type": "string", - "label": "Last name", - "required": true, - "validations": { - "min": 1, - "max": 100, - "message": "Last name is required" - } - }, - { - "name": "addressLine1", - "type": "string", - "label": "Address Line 1", - "required": true, - "validations": { - "min": 5, - "max": 200, - "message": "Address must be at least 5 characters" - } - }, - { - "name": "addressLine2", - "type": "string", - "label": "Address Line 2", - "required": false, - "validations": { - "max": 200 - } - }, - { - "name": "parish", - "type": "string", - "label": "Parish", - "required": true, - "validations": { - "regex": "^(christ-church|st-andrew|st-george|st-james|st-john|st-joseph|st-lucy|st-michael|st-peter|st-philip|st-thomas)$", - "message": "Must select a valid parish" - } - }, - { - "name": "postalCode", - "type": "string", - "label": "Postal Code", - "required": false, - "validations": { - "regex": "^BB\\d{5}$", - "message": "Enter a valid postal code (e.g., BB17004)" - } - }, - { - "name": "idNumber", - "type": "string", - "label": "National Identification (ID) Number", - "required": false, - "validations": { - "min": 2, - "message": "ID Number must be at least 2 characters" - } - }, - { - "name": "passportNumber", - "type": "string", - "label": "Passport Number", - "required": false, - "validations": { - "message": "Passport number must be at least 6 characters" - } - } - ] - }, - { - "name": "applyingForYourself", - "type": "string", - "label": "Are you applying for your own birth certificate?", - "required": true, - "validations": { - "regex": "^(yes|no)$", - "message": "Must select an option" - } - }, - { - "name": "relationshipToPerson", - "type": "string", - "label": "What is your relationship to the person?", - "required": false, - "validations": { - "message": "" - } - }, - { - "name": "relationshipOtherDescription", - "type": "string", - "label": "Please describe your relationship", - "required": false, - "validations": { - "message": "" - } - }, - { - "name": "reasonForOrderingCertificate", - "type": "string", - "label": "Tell us why you're ordering a birth certificate", - "required": false, - "validations": { - "message": "" - } - }, - { - "name": "personDeceased", - "type": "string", - "label": "Is the person deceased?", - "required": false, - "validations": { - "message": "" - } - }, - { - "name": "dateOfDeath", - "type": "string", - "label": "Date of death", - "required": false, - "validations": { - "message": "" - } - }, - { - "name": "person", - "type": "object", - "required": false, - "fields": [ - { - "name": "firstName", - "type": "string", - "label": "First name", - "required": false, - "validations": { - "min": 1, - "max": 100, - "message": "First name is required" - } - }, - { - "name": "middleName", - "type": "string", - "label": "Middle name", - "required": false, - "validations": { - "max": 100 - } - }, - { - "name": "lastName", - "type": "string", - "label": "Last name", - "required": false, - "validations": { - "min": 1, - "max": 100, - "message": "Last name is required" - } - }, - { - "name": "addressLine1", - "type": "string", - "label": "Address Line 1", - "required": false, - "validations": { - "min": 5, - "max": 200, - "message": "Address must be at least 5 characters" - } - }, - { - "name": "addressLine2", - "type": "string", - "label": "Address Line 2", - "required": false, - "validations": { - "max": 200 - } - }, - { - "name": "parish", - "type": "string", - "label": "Parish", - "required": false, - "validations": { - "regex": "^(christ-church|st-andrew|st-george|st-james|st-john|st-joseph|st-lucy|st-michael|st-peter|st-philip|st-thomas)$", - "message": "Must select a valid parish" - } - }, - { - "name": "postalCode", - "type": "string", - "label": "Postal Code", - "required": false, - "validations": { - "regex": "^BB\\d{5}$", - "message": "Enter a valid postal code (e.g., BB17004)" - } - }, - { - "name": "idNumber", - "type": "string", - "label": "National Identification (ID) Number", - "required": false, - "validations": { - "min": 2, - "message": "ID Number must be at least 2 characters" - } - }, - { - "name": "passportNumber", - "type": "string", - "label": "Passport Number", - "required": false, - "validations": { - "message": "Passport number must be at least 6 characters" - } - } - ] - }, - { - "name": "birthDetails", - "type": "object", - "required": false, - "fields": [ - { - "name": "dateOfBirth", - "type": "date", - "label": "Date of birth", - "required": false - }, - { - "name": "placeOfBirth", - "type": "string", - "label": "Place of birth", - "required": false, - "validations": { - "min": 2, - "max": 200, - "message": "Place of birth must be at least 2 characters" - } - }, - { - "name": "placeOfBaptism", - "type": "string", - "label": "Place of baptism", - "required": false, - "validations": { - "min": 2, - "max": 200, - "message": "Place of baptism must be at least 2 characters" - } - } - ] - }, - { - "name": "parents", - "type": "object", - "required": false, - "fields": [ - { - "name": "father", - "type": "object", - "required": false, - "fields": [ - { - "name": "firstName", - "type": "string", - "label": "Father's first name", - "required": false, - "validations": { - "min": 1, - "max": 100, - "message": "First name is required" - } - }, - { - "name": "middleName", - "type": "string", - "label": "Father's middle name", - "required": false, - "validations": { - "max": 100, - "message": "Middle name is required" - } - }, - { - "name": "lastName", - "type": "string", - "label": "Father's last name", - "required": false, - "validations": { - "min": 1, - "max": 100, - "message": "Last name is required" - } - } - ] - }, - { - "name": "mother", - "type": "object", - "required": false, - "fields": [ - { - "name": "firstName", - "type": "string", - "label": "Mother's first name", - "required": false, - "validations": { - "min": 1, - "max": 100, - "message": "First name is required" - } - }, - { - "name": "middleName", - "type": "string", - "label": "Mother's middle name", - "required": false, - "validations": { - "max": 100, - "message": "Middle name is required" - } - }, - { - "name": "lastName", - "type": "string", - "label": "Mother's last name", - "required": false, - "validations": { - "min": 1, - "max": 100, - "message": "Last name is required" - } - } - ] - } - ] - }, - { - "name": "parentsOther", - "type": "object", - "required": false, - "fields": [ - { - "name": "father", - "type": "object", - "required": false, - "fields": [ - { - "name": "firstName", - "type": "string", - "label": "Father's first name", - "required": false, - "validations": { - "min": 1, - "max": 100, - "message": "First name is required" - } - }, - { - "name": "middleName", - "type": "string", - "label": "Father's middle name", - "required": false, - "validations": { - "max": 100, - "message": "Middle name is required" - } - }, - { - "name": "lastName", - "type": "string", - "label": "Father's last name", - "required": false, - "validations": { - "min": 1, - "max": 100, - "message": "Last name is required" - } - } - ] - }, - { - "name": "mother", - "type": "object", - "required": false, - "fields": [ - { - "name": "firstName", - "type": "string", - "label": "Mother's first name", - "required": false, - "validations": { - "min": 1, - "max": 100, - "message": "First name is required" - } - }, - { - "name": "middleName", - "type": "string", - "label": "Mother's middle name", - "required": false, - "validations": { - "max": 100, - "message": "Middle name is required" - } - }, - { - "name": "lastName", - "type": "string", - "label": "Mother's last name", - "required": false, - "validations": { - "min": 1, - "max": 100, - "message": "Last name is required" - } - } - ] - } - ] - }, - { - "name": "order", - "type": "object", - "required": true, - "fields": [ - { - "name": "numberOfCopies", - "type": "number", - "label": "Number of copies", - "required": true, - "validations": { - "min": 1, - "max": 10, - "message": "You must order at least 1 copy and maximum 10 copies" - } - } - ] - } - ], - "processors": [ - { - "type": "payment", - "config": { - "provider": "ezpay", - "department": "revenue_authority", - "paymentCode": "{{db:get-birth-certificate:payment_code}}", - "amount": "{{formData.order.numberOfCopies * db:get-birth-certificate:payment_amount}}", - "description": "Birth Certificate Processing Fee (per copy)", - "required": true, - "timing": "after_validation", - "responseData": { - "include": ["order.numberOfCopies"] - } - } - }, - { - "type": "email", - "config": { - "to": "{{db:get-birth-certificate:admin_email}}", - "subject": "New Birth Certificate Application - {{formData.applicant.firstName}} {{formData.applicant.lastName}}", - "template": "birth-certificate" - } - } - ] + "id": "get-birth-certificate", + "name": "Get Birth Certificate", + "description": "Apply for a copy of your birth certificate or someone else's birth certificate", + "fields": [ + { + "name": "applicant", + "type": "object", + "required": true, + "fields": [ + { + "name": "title", + "type": "string", + "label": "Title", + "required": true, + "validations": { + "regex": "^(mr|ms|mrs)$", + "message": "Must select a valid title" + } + }, + { + "name": "firstName", + "type": "string", + "label": "First name", + "required": true, + "validations": { + "min": 1, + "max": 100, + "message": "First name is required" + } + }, + { + "name": "middleName", + "type": "string", + "label": "Middle name", + "required": false, + "validations": { + "max": 100 + } + }, + { + "name": "lastName", + "type": "string", + "label": "Last name", + "required": true, + "validations": { + "min": 1, + "max": 100, + "message": "Last name is required" + } + }, + { + "name": "addressLine1", + "type": "string", + "label": "Address line 1", + "required": true, + "validations": { + "min": 5, + "max": 200, + "message": "Address must be at least 5 characters" + } + }, + { + "name": "addressLine2", + "type": "string", + "label": "Address line 2", + "required": false, + "validations": { + "max": 200 + } + }, + { + "name": "parish", + "type": "string", + "label": "Parish", + "required": true, + "validations": { + "regex": "^(christ-church|st-andrew|st-george|st-james|st-john|st-joseph|st-lucy|st-michael|st-peter|st-philip|st-thomas)$", + "message": "Must select a valid parish" + } + }, + { + "name": "postalCode", + "type": "string", + "label": "Postal Code", + "required": false, + "validations": { + "regex": "^BB\\d{5}$", + "message": "Enter a valid postal code (e.g., BB17004)" + } + }, + { + "name": "idNumber", + "type": "string", + "label": "National Identification (ID) Number", + "required": false, + "validations": { + "min": 2, + "message": "ID Number must be at least 2 characters" + } + }, + { + "name": "passportNumber", + "type": "string", + "label": "Passport Number", + "required": false, + "validations": { + "message": "Passport number must be at least 6 characters" + } + }, + { + "name": "email", + "type": "email", + "label": "Email address", + "required": true + }, + { + "name": "telephoneNumber", + "type": "string", + "label": "Telephone number", + "required": true + } + ] + }, + { + "name": "applyingForYourself", + "type": "string", + "label": "Are you applying for your own birth certificate?", + "required": true, + "validations": { + "regex": "^(yes|no)$", + "message": "Must select an option" + } + }, + { + "name": "relationshipToPerson", + "type": "string", + "label": "What is your relationship to the person?", + "required": false, + "validations": { + "message": "" + } + }, + { + "name": "relationshipOtherDescription", + "type": "string", + "label": "Please describe your relationship", + "required": false, + "validations": { + "message": "" + } + }, + { + "name": "reasonForOrderingCertificate", + "type": "string", + "label": "Tell us why you're ordering a birth certificate", + "required": false, + "validations": { + "message": "" + } + }, + { + "name": "personDeceased", + "type": "string", + "label": "Is the person deceased?", + "required": false, + "validations": { + "message": "" + } + }, + { + "name": "dateOfDeath", + "type": "string", + "label": "Date of death", + "required": false, + "validations": { + "message": "" + } + }, + { + "name": "person", + "type": "object", + "required": false, + "fields": [ + { + "name": "firstName", + "type": "string", + "label": "First name", + "required": false, + "validations": { + "max": 100, + "message": "First name is required" + } + }, + { + "name": "middleName", + "type": "string", + "label": "Middle name", + "required": false, + "validations": { + "max": 100 + } + }, + { + "name": "lastName", + "type": "string", + "label": "Last name", + "required": false, + "validations": { + "max": 100, + "message": "Last name is required" + } + }, + { + "name": "addressLine1", + "type": "string", + "label": "Address Line 1", + "required": false, + "validations": { + "max": 200, + "message": "Address must be at least 5 characters" + } + }, + { + "name": "addressLine2", + "type": "string", + "label": "Address Line 2", + "required": false, + "validations": { + "max": 200 + } + }, + { + "name": "parish", + "type": "string", + "label": "Parish", + "required": false, + "validations": { + "regex": "^(christ-church|st-andrew|st-george|st-james|st-john|st-joseph|st-lucy|st-michael|st-peter|st-philip|st-thomas)$", + "message": "Must select a valid parish" + } + }, + { + "name": "postalCode", + "type": "string", + "label": "Postal Code", + "required": false, + "validations": { + "regex": "^BB\\d{5}$", + "message": "Enter a valid postal code (e.g., BB17004)" + } + }, + { + "name": "idNumber", + "type": "string", + "label": "National Identification (ID) Number", + "required": false, + "validations": { + "message": "ID Number must be at least 2 characters" + } + }, + { + "name": "passportNumber", + "type": "string", + "label": "Passport Number", + "required": false, + "validations": { + "message": "Passport number must be at least 6 characters" + } + } + ] + }, + { + "name": "birthDetails", + "type": "object", + "required": false, + "fields": [ + { + "name": "dateOfBirth", + "type": "date", + "label": "Date of birth", + "required": false + }, + { + "name": "placeOfBirth", + "type": "string", + "label": "Place of birth", + "required": false, + "validations": { + "min": 2, + "max": 200, + "message": "Place of birth must be at least 2 characters" + } + }, + { + "name": "placeOfBaptism", + "type": "string", + "label": "Place of baptism", + "required": false, + "validations": { + "min": 2, + "max": 200, + "message": "Place of baptism must be at least 2 characters" + } + } + ] + }, + { + "name": "parents", + "type": "object", + "required": false, + "fields": [ + { + "name": "father", + "type": "object", + "required": false, + "fields": [ + { + "name": "firstName", + "type": "string", + "label": "Father's first name", + "required": false, + "validations": { + "min": 1, + "max": 100, + "message": "First name is required" + } + }, + { + "name": "middleName", + "type": "string", + "label": "Father's middle name", + "required": false, + "validations": { + "max": 100, + "message": "Middle name is required" + } + }, + { + "name": "lastName", + "type": "string", + "label": "Father's last name", + "required": false, + "validations": { + "min": 1, + "max": 100, + "message": "Last name is required" + } + } + ] + }, + { + "name": "mother", + "type": "object", + "required": false, + "fields": [ + { + "name": "firstName", + "type": "string", + "label": "Mother's first name", + "required": false, + "validations": { + "min": 1, + "max": 100, + "message": "First name is required" + } + }, + { + "name": "middleName", + "type": "string", + "label": "Mother's middle name", + "required": false, + "validations": { + "max": 100, + "message": "Middle name is required" + } + }, + { + "name": "lastName", + "type": "string", + "label": "Mother's last name", + "required": false, + "validations": { + "min": 1, + "max": 100, + "message": "Last name is required" + } + } + ] + } + ] + }, + { + "name": "parentsOther", + "type": "object", + "required": false, + "fields": [ + { + "name": "father", + "type": "object", + "required": false, + "fields": [ + { + "name": "firstName", + "type": "string", + "label": "Father's first name", + "required": false, + "validations": { + "max": 100, + "message": "First name is required" + } + }, + { + "name": "middleName", + "type": "string", + "label": "Father's middle name", + "required": false, + "validations": { + "max": 100, + "message": "Middle name is required" + } + }, + { + "name": "lastName", + "type": "string", + "label": "Father's last name", + "required": false, + "validations": { + "max": 100, + "message": "Last name is required" + } + } + ] + }, + { + "name": "mother", + "type": "object", + "required": false, + "fields": [ + { + "name": "firstName", + "type": "string", + "label": "Mother's first name", + "required": false, + "validations": { + "max": 100, + "message": "First name is required" + } + }, + { + "name": "middleName", + "type": "string", + "label": "Mother's middle name", + "required": false, + "validations": { + "max": 100, + "message": "Middle name is required" + } + }, + { + "name": "lastName", + "type": "string", + "label": "Mother's last name", + "required": false, + "validations": { + "max": 100, + "message": "Last name is required" + } + } + ] + } + ] + }, + { + "name": "order", + "type": "object", + "required": true, + "fields": [ + { + "name": "numberOfCopies", + "type": "number", + "label": "Number of copies", + "required": true, + "validations": { + "min": 1, + "max": 10, + "message": "You must order at least 1 copy and maximum 10 copies" + } + } + ] + } + ], + "processors": [ + { + "type": "payment", + "config": { + "provider": "ezpay", + "department": "revenue_authority", + "paymentCode": "{{db:get-birth-certificate:payment_code}}", + "amount": "{{formData.order.numberOfCopies * db:get-birth-certificate:payment_amount}}", + "description": "Birth Certificate Processing Fee (per copy)", + "required": true, + "timing": "after_validation", + "responseData": { + "include": ["order.numberOfCopies"] + } + } + }, + { + "type": "email", + "config": { + "to": "{{db:get-birth-certificate:admin_email}}", + "subject": "New Birth Certificate Application - {{formData.applicant.firstName}} {{formData.applicant.lastName}}", + "template": "birth-certificate" + } + } + ] } From e8e5b56e7f56d0c15210eed8a7a23f30e0636a7c Mon Sep 17 00:00:00 2001 From: Shannon Clarke Date: Mon, 19 Jan 2026 11:22:58 -0400 Subject: [PATCH 09/49] fix: add missing contact fields and new field to marriage certificate --- schemas/get-marriage-certificate.json | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/schemas/get-marriage-certificate.json b/schemas/get-marriage-certificate.json index ce80a9a..51e0deb 100644 --- a/schemas/get-marriage-certificate.json +++ b/schemas/get-marriage-certificate.json @@ -52,7 +52,7 @@ { "name": "addressLine1", "type": "string", - "label": "Address Line 1", + "label": "Address line 1", "required": true, "validations": { "min": 5, @@ -63,7 +63,7 @@ { "name": "addressLine2", "type": "string", - "label": "Address Line 2", + "label": "Address line 2", "required": false, "validations": { "max": 200 @@ -107,6 +107,28 @@ "validations": { "message": "Passport number must be at least 6 characters" } + }, + { + "name": "email", + "type": "email", + "label": "Email Address", + "required": true + }, + { + "name": "telephoneNumber", + "type": "string", + "label": "Telephone Number", + "required": true + }, + { + "name": "isBarbadosNational", + "type": "string", + "label": "Are you a Barbados national?", + "required": true, + "validations": { + "regex": "^(yes|no)$", + "message": "Must select an option" + } } ] }, From 4ab11787c4bb40f773582abdf7333366ad84c0d9 Mon Sep 17 00:00:00 2001 From: Shannon Clarke Date: Tue, 20 Jan 2026 03:55:14 -0400 Subject: [PATCH 10/49] feat: add opencrvs integration (#39) * feat: add opencrvs integration * Updated Keys For Birth Form Submission * Added birth place address for Open CRVS submission * Fixed Validations + Updated Mapping * Added Informant Info + Fixed Alive Stats * Made Email Dynamic --------- Co-authored-by: ihtishamtanveer Co-authored-by: = <=> --- schemas/register-birth-form.json | 232 +++++--- src/config/configuration.ts | 22 + src/forms/dto/form-submission-response.dto.ts | 19 +- src/forms/forms.service.ts | 49 +- src/forms/interfaces/form-schema.interface.ts | 13 + src/opencrvs/birth-registration.mapper.ts | 520 ++++++++++++++++++ src/opencrvs/index.ts | 7 + src/opencrvs/opencrvs.module.ts | 11 + src/opencrvs/opencrvs.service.ts | 336 +++++++++++ src/opencrvs/types/index.ts | 1 + src/opencrvs/types/opencrvs.types.ts | 239 ++++++++ .../implementations/opencrvs.processor.ts | 171 ++++++ src/processors/processor-pipeline.service.ts | 16 +- src/processors/processors.module.ts | 6 + 14 files changed, 1565 insertions(+), 77 deletions(-) create mode 100644 src/opencrvs/birth-registration.mapper.ts create mode 100644 src/opencrvs/index.ts create mode 100644 src/opencrvs/opencrvs.module.ts create mode 100644 src/opencrvs/opencrvs.service.ts create mode 100644 src/opencrvs/types/index.ts create mode 100644 src/opencrvs/types/opencrvs.types.ts create mode 100644 src/processors/implementations/opencrvs.processor.ts diff --git a/schemas/register-birth-form.json b/schemas/register-birth-form.json index 7b563dd..582f48b 100644 --- a/schemas/register-birth-form.json +++ b/schemas/register-birth-form.json @@ -53,28 +53,49 @@ } }, { - "name": "hadOtherSurname", + "name": "age", "type": "string", - "required": false, + "required": { + "field": "idNumber", + "value": [null, ""], + "message": "Age is required when no ID number is provided" + }, "validations": { - "regex": "^(yes|no)$" + "condition": { + "field": "idNumber", + "operator": "notIn", + "value": [null, ""], + "then": { + "min": 0, + "message": "Age not required" + }, + "else": { + "min": 1, + "message": "Age is required" + } + } } }, { - "name": "otherSurname", + "name": "parish", "type": "string", - "required": false, + "required": true, "validations": { - "max": 100 + "min": 2, + "max": 500 } }, { - "name": "dateOfBirth", - "type": "date", - "required": false + "name": "addressLine1", + "type": "string", + "required": true, + "validations": { + "min": 2, + "max": 500 + } }, { - "name": "address", + "name": "addressLine2", "type": "string", "required": false, "validations": { @@ -82,7 +103,7 @@ } }, { - "name": "nationalRegistrationNumber", + "name": "idNumber", "type": "string", "required": false, "validations": { @@ -99,20 +120,110 @@ } }, { - "name": "passportPlaceOfIssue", + "name": "occupation", "type": "string", "required": false, "validations": { "max": 100 } + } + ] + }, + { + "name": "birth", + "type": "object", + "required": true, + "fields": [ + { + "name": "placeOfBirth", + "type": "string", + "label": "Where did the birth take place?", + "required": true, + "validations": { + "regex": "^(health-facility|residential|other)$", + "message": "Must select an option" + } }, { - "name": "occupation", + "name": "parish", "type": "string", - "required": false, + "label": "Parish", + "required": { + "field": "birth.placeOfBirth", + "value": ["residential", "other"], + "message": "Parish is required for residential/other birth locations" + }, "validations": { - "max": 100 + "condition": { + "field": "birth.placeOfBirth", + "operator": "in", + "value": ["residential", "other"], + "then": { + "regex": "^(christ-church|st-andrew|st-george|st-james|st-john|st-joseph|st-lucy|st-michael|st-peter|st-philip|st-thomas)$", + "message": "Must select a valid parish" + } + } + } + }, + { + "name": "streetAddress", + "type": "string", + "label": "Street Address", + "required": { + "field": "birth.placeOfBirth", + "value": ["residential", "other"], + "message": "Street address is required for residential/other birth locations" + }, + "validations": { + "condition": { + "field": "birth.placeOfBirth", + "operator": "in", + "value": ["residential", "other"], + "then": { + "min": 5, + "max": 200, + "message": "Address must be at least 5 characters" + } + } } + }, + { + "name": "numberOfBirths", + "type": "string", + "label": "How many births do you need to register?", + "required": true, + "validations": { + "regex": "^(single|twins|triplets|more-than-triplets)$", + "message": "Must select an option" + } + }, + { + "name": "attendantAtBirth", + "type": "string", + "label": "Attendant at birth", + "required": true, + "validations": { + "regex": "^(doctor|midwife|nurse|relative|none)$", + "message": "Must select an option" + } + }, + { + "name": "liveBorn", + "type": "string", + "label": "Live born", + "required": false + }, + { + "name": "stillBorn", + "type": "string", + "label": "Still born", + "required": false + }, + { + "name": "totalStillAlive", + "type": "string", + "label": "Total still alive", + "required": false } ] }, @@ -148,58 +259,54 @@ } }, { - "name": "hadOtherSurname", + "name": "maidenSurname", "type": "string", "required": false, "validations": { - "regex": "^(yes|no)$" + "max": 100 } }, { - "name": "otherSurname", + "name": "parish", "type": "string", - "required": false, + "required": true, "validations": { - "max": 100 + "min": 2, + "max": 500 } }, { - "name": "dateOfBirth", - "type": "date", - "required": true - }, - { - "name": "address", + "name": "addressLine1", "type": "string", "required": true, "validations": { - "min": 5, + "min": 2, "max": 500 } }, { - "name": "nationalRegistrationNumber", + "name": "addressLine2", "type": "string", "required": false, "validations": { - "regex": "^\\d{6}-\\d{4}$", - "message": "Must be in format XXXXXX-XXXX" + "max": 500 } }, { - "name": "passportNumber", + "name": "idNumber", "type": "string", "required": false, "validations": { - "max": 50 + "regex": "^\\d{6}-\\d{4}$", + "message": "Must be in format XXXXXX-XXXX" } }, { - "name": "passportPlaceOfIssue", + "name": "passportNumber", "type": "string", "required": false, "validations": { - "max": 100 + "max": 50 } }, { @@ -219,7 +326,7 @@ "required": true, "fields": [ { - "name": "firstNames", + "name": "firstName", "type": "string", "required": true, "validations": { @@ -228,7 +335,7 @@ } }, { - "name": "middleNames", + "name": "middleName", "type": "string", "required": false, "validations": { @@ -254,51 +361,44 @@ "type": "string", "required": true, "validations": { - "regex": "^(Male|Female)$", - "message": "Must be 'Male' or 'Female'" + "regex": "^(male|female)$", + "message": "Must be 'male' or 'female'" } - }, + } + ] + }, + { + "name": "order", + "type": "object", + "required": true, + "fields": [ { - "name": "parishOfBirth", - "type": "string", + "name": "numberOfCopies", + "type": "number", + "label": "Number of copies", "required": true, "validations": { "min": 1, - "max": 100 + "max": 10, + "message": "You must order at least 1 copy and maximum 10 copies" } } ] - }, - { - "name": "numberOfCertificates", - "type": "number", - "required": true, - "validations": { - "min": 0, - "max": 20 - } - }, - { - "name": "email", - "type": "email", - "required": true - }, - { - "name": "phoneNumber", - "type": "string", - "required": true, - "validations": { - "min": 7, - "max": 20 - } } ], "processors": [ + { + "type": "opencrvs", + "config": { + "eventType": "birth", + "officeName": "Registration District A" + } + }, { "type": "email", "config": { "to": "{{db:register-birth-form:admin_email}}", - "subject": "New Birth Registration - {{formData.child.firstNames}} {{formData.child.lastName}}", + "subject": "New Birth Registration - {{formData.child.firstName}} {{formData.child.lastName}}", "template": "birth-registration" } }, diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 30e21a0..cc5e75d 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -56,4 +56,26 @@ export default () => ({ default: process.env.EZPAY_API_KEY || 'HWqgTn5EXIHLAzVjXtGpB2mIjgQgj0Ql', }, }, + opencrvs: { + // Use localhost URLs when running locally against OpenCRVS dev environment + localhost: process.env.OPENCRVS_LOCALHOST === 'true', + // Production/QA URLs + authBaseUrl: + process.env.OPENCRVS_AUTH_URL || 'https://auth.barbados-qa.opencrvs.org', + eventsBaseUrl: + process.env.OPENCRVS_EVENTS_URL || + 'https://register.barbados-qa.opencrvs.org', + locationsBaseUrl: + process.env.OPENCRVS_LOCATIONS_URL || + 'https://gateway.barbados-qa.opencrvs.org', + // Authentication credentials + clientId: process.env.OPENCRVS_CLIENT_ID, + clientSecret: process.env.OPENCRVS_CLIENT_SECRET, + // Default location names (resolved to IDs at runtime) + defaultOfficeName: + process.env.OPENCRVS_DEFAULT_OFFICE || + 'Registration Department Records Branch', + defaultHealthFacilityName: process.env.OPENCRVS_DEFAULT_HEALTH_FACILITY, + defaultParishName: process.env.OPENCRVS_DEFAULT_PARISH || 'Christ Church', + }, }); diff --git a/src/forms/dto/form-submission-response.dto.ts b/src/forms/dto/form-submission-response.dto.ts index 087b19a..b0588ad 100644 --- a/src/forms/dto/form-submission-response.dto.ts +++ b/src/forms/dto/form-submission-response.dto.ts @@ -1,3 +1,13 @@ +type OpenCRVSIntegrationResult = { + success: boolean; + message: string; + trackingId?: string; +}; + +type IntegrationsResult = { + opencrvs?: OpenCRVSIntegrationResult; +}; + export class FormSubmissionResponseDto { submissionId: string; formId: string; @@ -13,8 +23,11 @@ export class FormSubmissionResponseDto { amount?: number; description?: string; + // Integration results + integrations?: IntegrationsResult; + // Dynamic additional data - [key: string]: any; + [key: string]: unknown; constructor( submissionId: string, @@ -29,7 +42,7 @@ export class FormSubmissionResponseDto { amount?: number; description?: string; }, - additionalData?: Record, + additionalData?: Record, ) { this.submissionId = submissionId; this.formId = formId; @@ -46,7 +59,7 @@ export class FormSubmissionResponseDto { this.description = paymentInfo.description; } - // Add any additional dynamic data + // Add any additional dynamic data (including integrations) if (additionalData) { Object.assign(this, additionalData); } diff --git a/src/forms/forms.service.ts b/src/forms/forms.service.ts index 434a2c2..39d1e82 100644 --- a/src/forms/forms.service.ts +++ b/src/forms/forms.service.ts @@ -5,6 +5,7 @@ import { SchemaBuilderService } from '../validation/schema-builder.service'; import { ProcessorPipelineService } from '../processors/processor-pipeline.service'; import { FormSubmissionResponseDto } from './dto'; import { FormUtilsService } from './form-utils.service'; +import { OpenCRVSProcessorResult } from '../opencrvs/types'; @Injectable() export class FormsService { @@ -83,16 +84,24 @@ export class FormsService { } // Execute processor pipeline for non-payment forms - await this.processorPipeline.execute(formSchemaWithData.processors, { - formId, - submissionId, - data, - }); + const processorResults = await this.processorPipeline.execute( + formSchemaWithData.processors, + { + formId, + submissionId, + data, + }, + ); + + // Build integrations result from processor results + const additionalData = this.buildIntegrationsResult(processorResults); const response = new FormSubmissionResponseDto( submissionId, formId, 'success', + undefined, + additionalData, ); return { @@ -101,6 +110,36 @@ export class FormsService { }; } + /** + * Build integrations result object from processor results + */ + private buildIntegrationsResult( + processorResults: Map, + ): Record | undefined { + const opencrvsResult = processorResults.get( + 'opencrvs', + ) as OpenCRVSProcessorResult | undefined; + + if (!opencrvsResult) { + return undefined; + } + + const integrations: Record = { + opencrvs: opencrvsResult.success + ? { + success: true, + message: 'Birth registration submitted successfully', + trackingId: opencrvsResult.trackingId, + } + : { + success: false, + message: opencrvsResult.error ?? 'Birth registration failed', + }, + }; + + return { integrations }; + } + /** * Process form submission that requires payment */ diff --git a/src/forms/interfaces/form-schema.interface.ts b/src/forms/interfaces/form-schema.interface.ts index 9556d6f..b0ce0b9 100644 --- a/src/forms/interfaces/form-schema.interface.ts +++ b/src/forms/interfaces/form-schema.interface.ts @@ -73,3 +73,16 @@ export interface PaymentProcessorConfig extends ProcessorConfig { export interface ResponseDataConfig { include?: string[]; // Form field paths to include (e.g., ['order.numberOfCopies', 'applicant.email']) } + +export interface OpenCRVSProcessorConfig extends ProcessorConfig { + type: 'opencrvs'; + config: { + eventType: 'birth'; // Currently only birth is supported + officeId?: string; // Direct location ID for CRVS office + officeName?: string; // Location name (resolved to ID at runtime) + healthFacilityId?: string; // Direct location ID for health facility + healthFacilityName?: string; // Location name (resolved to ID at runtime) + parishId?: string; // Direct location ID for parish + parishName?: string; // Location name (resolved to ID at runtime) + }; +} diff --git a/src/opencrvs/birth-registration.mapper.ts b/src/opencrvs/birth-registration.mapper.ts new file mode 100644 index 0000000..0783210 --- /dev/null +++ b/src/opencrvs/birth-registration.mapper.ts @@ -0,0 +1,520 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { + BirthDeclaration, + Gender, + PlaceOfBirth, + BirthType, + AttendantAtBirth, + MaritalStatus, + IdType, + DomesticAddress, + PersonName, + AgeReference, +} from './types'; + +/** + * Form data structure from register-birth-form.json schema + * Updated to match the current schema structure + */ +export type BirthRegistrationFormData = { + marriageStatus: 'yes' | 'no'; + includeFatherDetails?: 'yes' | 'no'; + + father?: { + firstName?: string; + middleName?: string; + lastName?: string; + age?: string; + parish?: string; + addressLine1?: string; + addressLine2?: string; + idNumber?: string; // National ID in format XXXXXX-XXXX + passportNumber?: string; + occupation?: string; + }; + + birth: { + placeOfBirth: 'health-facility' | 'residential' | 'other'; + parish: string; + streetAddress: string; + numberOfBirths: 'single' | 'twins' | 'triplets' | 'more-than-triplets'; + attendantAtBirth: 'doctor' | 'midwife' | 'nurse' | 'relative' | 'none'; + liveBorn?: string; + stillBorn?: string; + totalStillAlive?: string; + bornAlive?: string; + stillborn?: string; + }; + + mother: { + firstName: string; + middleName?: string; + lastName: string; + maidenSurname?: string; + parish: string; + addressLine1: string; + addressLine2?: string; + idNumber?: string; // National ID in format XXXXXX-XXXX + passportNumber?: string; + occupation: string; + telephoneNumber?: string; + }; + + child: { + firstName: string; + middleName?: string; + lastName: string; + dateOfBirth: string; + sexAtBirth: 'male' | 'female'; + }; + + order: { + numberOfCopies: number; + }; +}; + +/** + * Configuration for the mapper + */ +type MapperConfig = { + parishId: string; + healthFacilityId?: string; + defaultNationality?: string; +}; + +/** + * Service to map form submission data to OpenCRVS BirthDeclaration format + * + * This mapper transforms the data structure from the Barbados birth registration + * form into the format expected by the OpenCRVS API. + * + * Note: Informant fields are not required per OpenCRVS Barbados configuration. + */ +@Injectable() +export class BirthRegistrationMapper { + private readonly logger = new Logger(BirthRegistrationMapper.name); + + /** + * Map form data to OpenCRVS BirthDeclaration + * Accepts Record for flexibility with processor context + */ + mapToBirthDeclaration( + formData: BirthRegistrationFormData | Record, + config: MapperConfig, + ): BirthDeclaration { + const data = formData as BirthRegistrationFormData; + + this.logger.log('Mapping form data to OpenCRVS BirthDeclaration'); + + // Map the form's place of birth to OpenCRVS PlaceOfBirth + const openCRVSPlaceOfBirth = this.mapPlaceOfBirth(data.birth.placeOfBirth); + + const declaration: BirthDeclaration = { + // Child information (required) + 'child.name': this.mapChildName(data.child), + 'child.gender': this.mapGender(data.child.sexAtBirth), + 'child.dob': this.formatDate(data.child.dateOfBirth), + 'child.placeOfBirth': openCRVSPlaceOfBirth, + 'child.birthType': this.mapBirthType(data.birth.numberOfBirths), + 'child.attendantAtBirth': this.mapAttendantAtBirth( + data.birth.attendantAtBirth, + ), + + // Set birth location based on place of birth + ...(openCRVSPlaceOfBirth === 'HEALTH_FACILITY' + ? config.healthFacilityId && { + 'child.birthLocation': config.healthFacilityId, + } + : { + 'child.birthLocation.privateHome': { + addressType: 'DOMESTIC', + country: 'BRB', + administrativeArea: config.parishId, + streetLevelDetails: { + street: data.birth.streetAddress || 'sample address', + }, + }, + }), + + // Mother information (always provided) + 'mother.detailsNotAvailable': false, + 'mother.name': this.mapMotherName(data.mother), + 'mother.age': data.mother.idNumber ? this.calculateAgeFromNRN(data.mother.idNumber) : this.calculateAge(data.mother, data.child.dateOfBirth), + 'mother.maritalStatus': this.mapMaritalStatus(data.marriageStatus), + 'mother.nationality': config.defaultNationality ?? 'BRB', + ...this.mapMotherId(data.mother), + 'mother.address': this.mapAddress( + data.mother.addressLine1, + data.mother.addressLine2, + config.parishId, + ), + 'mother.occupation': data.mother.occupation, + 'mother.bornAlive': Number(data.birth.bornAlive), + 'mother.stillborn': Number(data.birth.stillborn), + 'mother.stillAlive': Number(data.birth.totalStillAlive), + + // Informant information + 'informant.relation': (data.marriageStatus === 'yes') ? 'PARENT' : 'OTHER', + 'informant.parentsMarried': (data.marriageStatus === 'yes') ? 'YES' : 'NO', + 'informant.phoneNo': data.mother.telephoneNumber, + + // Father information (conditional) + ...this.mapFatherDetails(data, config), + }; + + this.logger.log( + `Mapped birth declaration for child: ${declaration['child.name'].firstname} ${declaration['child.name'].surname}`, + ); + + return declaration; + } + + /** + * Map form's place of birth to OpenCRVS PlaceOfBirth enum + */ + private mapPlaceOfBirth( + placeOfBirth: BirthRegistrationFormData['birth']['placeOfBirth'], + ): PlaceOfBirth { + switch (placeOfBirth) { + case 'health-facility': + return 'HEALTH_FACILITY'; + case 'residential': + return 'PRIVATE_HOME'; + case 'other': + default: + return 'OTHER'; + } + } + + /** + * Map number of births to OpenCRVS BirthType + */ + private mapBirthType( + numberOfBirths: BirthRegistrationFormData['birth']['numberOfBirths'], + ): BirthType { + switch (numberOfBirths) { + case 'single': + return 'SINGLE'; + case 'twins': + return 'TWIN'; + case 'triplets': + return 'TRIPLET'; + case 'more-than-triplets': + return 'QUADRUPLET_OR_MORE'; + default: + return 'SINGLE'; + } + } + + /** + * Map attendant at birth to OpenCRVS AttendantAtBirth + */ + private mapAttendantAtBirth( + attendant: BirthRegistrationFormData['birth']['attendantAtBirth'], + ): AttendantAtBirth { + switch (attendant) { + case 'doctor': + return 'DOCTOR'; + case 'midwife': + return 'MIDWIFE'; + case 'nurse': + return 'NURSE'; + case 'relative': + return 'RELATIVE'; + case 'none': + return 'NONE'; + default: + return 'OTHER'; + } + } + + /** + * Map marriage status to OpenCRVS MaritalStatus + */ + private mapMaritalStatus(marriageStatus: 'yes' | 'no'): MaritalStatus { + return marriageStatus === 'yes' ? 'MARRIED' : 'SINGLE'; + } + + /** + * Map child name from form data + */ + private mapChildName(child: BirthRegistrationFormData['child']): PersonName { + const fullFirstName = child.middleName + ? `${child.firstName} ${child.middleName}` + : child.firstName; + + return { + firstname: fullFirstName, + surname: child.lastName, + }; + } + + /** + * Map mother name from form data + */ + private mapMotherName( + mother: BirthRegistrationFormData['mother'], + ): PersonName { + const fullFirstName = mother.middleName + ? `${mother.firstName} ${mother.middleName}` + : mother.firstName; + + return { + firstname: fullFirstName, + surname: mother.lastName, + }; + } + + /** + * Map father name from form data + */ + private mapFatherName( + father: NonNullable, + ): PersonName { + const fullFirstName = father.middleName + ? `${father.firstName} ${father.middleName}` + : father.firstName ?? ''; + + return { + firstname: fullFirstName, + surname: father.lastName ?? '', + }; + } + + /** + * Map sex at birth to OpenCRVS gender + */ + private mapGender(sexAtBirth: 'male' | 'female'): Gender { + return sexAtBirth; + } + + /** + * Format date to YYYY-MM-DD format expected by OpenCRVS + */ + private formatDate(dateString: string): string { + if (!dateString) { + return ''; + } + + // If already in YYYY-MM-DD format, return as is + if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) { + return dateString; + } + + // Try to parse and format the date + try { + const date = new Date(dateString); + return date.toISOString().slice(0, 10); + } catch { + this.logger.warn(`Could not parse date: ${dateString}`); + return dateString; + } + } + + /** + * Calculate age reference from parent data + * OpenCRVS uses age relative to child's DOB + */ + private calculateAge( + parent: { age?: string } | BirthRegistrationFormData['mother'], + childDob: string, + ): AgeReference | undefined { + const ageStr = 'age' in parent ? parent.age : undefined; + if (!ageStr) { + return undefined; + } + + const age = parseInt(ageStr, 10); + if (isNaN(age)) { + return undefined; + } + + return { + age, + asOfDateRef: 'child.dob', + }; + } + + /** + * Map address to OpenCRVS DomesticAddress format + * Uses streetLevelDetails.street as per OpenCRVS API format + */ + private mapAddress( + addressLine1: string, + addressLine2: string | undefined, + parishId: string, + ): DomesticAddress { + const streetAddress = addressLine2 + ? `${addressLine1}, ${addressLine2}` + : addressLine1; + + return { + addressType: 'DOMESTIC', + country: 'BRB', + administrativeArea: parishId, + streetLevelDetails: { + street: streetAddress, + }, + }; + } + + /** + * Map mother's ID type and number + * Note: OpenCRVS Barbados only accepts passport as ID type, not national ID + */ + private mapMotherId(mother: BirthRegistrationFormData['mother']): { + 'mother.idType'?: IdType; + 'mother.passport'?: string; + 'mother.nationalRegistrationNumber'?: string; + } { + if (mother.idNumber) { + return { + 'mother.idType': 'NATIONAL_REGISTRATION_NUMBER', + 'mother.nationalRegistrationNumber': mother.idNumber, + }; + } + if (mother.passportNumber) { + return { + 'mother.idType': 'PASSPORT', + 'mother.passport': mother.passportNumber, + }; + } + + // OpenCRVS Barbados doesn't support national ID (nid field) + // so we set idType to NONE if no passport is provided + return { + 'mother.idType': 'NONE', + }; + } + + /** + * Map father's ID type and number + * Note: OpenCRVS Barbados only accepts passport as ID type, not national ID + */ + private mapFatherId( + father: NonNullable, + ): { + 'father.idType'?: IdType; + 'father.passport'?: string; + 'father.nationalRegistrationNumber'?: string; + } { + if (father.idNumber) { + return { + 'father.idType': 'NATIONAL_REGISTRATION_NUMBER', + 'father.nationalRegistrationNumber': father.idNumber, + }; + } + if (father.passportNumber) { + return { + 'father.idType': 'PASSPORT', + 'father.passport': father.passportNumber, + }; + } + + // OpenCRVS Barbados doesn't support national ID (nid field) + // so we set idType to NONE if no passport is provided + return { + 'father.idType': 'NONE', + }; + } + + /** + * Map father details based on form conditions + * Father details are included if: + * - Parents are married (marriageStatus === 'yes') + * - OR user explicitly wants to include father details (includeFatherDetails === 'yes') + */ + private mapFatherDetails( + formData: BirthRegistrationFormData, + config: MapperConfig, + ): Partial { + const includeFather = + formData.marriageStatus === 'yes' || + formData.includeFatherDetails === 'yes'; + + if (!includeFather || !formData.father) { + return { + 'father.detailsNotAvailable': true, + 'father.reason': 'Details not provided', + }; + } + + const father = formData.father; + + // Check if father has sufficient details + if (!father.firstName || !father.lastName) { + return { + 'father.detailsNotAvailable': true, + 'father.reason': 'Incomplete father details', + }; + } + + // Determine if father uses same address as mother + const useSameAddress = !father.addressLine1; + + return { + 'father.detailsNotAvailable': false, + 'father.name': this.mapFatherName(father), + 'father.age': father.idNumber ? this.calculateAgeFromNRN(father.idNumber) : this.calculateAge(father, formData.child.dateOfBirth), + 'father.nationality': config.defaultNationality ?? 'BRB', + ...this.mapFatherId(father), + 'father.occupation': father.occupation, + ...(useSameAddress + ? { 'father.addressSameAs': true } + : { + 'father.address': this.mapAddress( + father.addressLine1!, + father.addressLine2, + config.parishId, + ), + }), + }; + } + + /** + * Calculates age from a National Registration Number (NRN) in format YYMMDD-XXXX + * @param nrn The National Registration Number (e.g., "920320-0016") + * @returns The age in years + */ + private calculateAgeFromNRN(nrn: string): { age: number; asOfDateRef: string } { + // Extract the date parts from NRN + const year = parseInt(nrn.substring(0, 2)); + const month = parseInt(nrn.substring(2, 4)) - 1; // JavaScript months are 0-indexed + const day = parseInt(nrn.substring(4, 6)); + + // Handle Y2K: Assume 1900s for years 00-20, 2000s for 21-99 + const fullYear = year <= 20 ? 2000 + year : 1900 + year; + + // Create date objects + const birthDate = new Date(fullYear, month, day); + const today = new Date(); + + // Calculate age + let age = today.getFullYear() - birthDate.getFullYear(); + const monthDiff = today.getMonth() - birthDate.getMonth(); + + // Adjust age if birthday hasn't occurred yet this year + if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) { + age--; + } + + return { + age, + asOfDateRef: 'child.dob' + }; + } + + + /** + * Create annotation object for OpenCRVS + * OpenCRVS has a strict schema and rejects unknown fields, + * so we return an empty object. Form metadata is preserved + * in our own submission records. + */ + createAnnotation( + _formData: BirthRegistrationFormData | Record, + ): Record { + // OpenCRVS rejects custom annotation fields - return empty object + return {}; + } +} diff --git a/src/opencrvs/index.ts b/src/opencrvs/index.ts new file mode 100644 index 0000000..3f43eee --- /dev/null +++ b/src/opencrvs/index.ts @@ -0,0 +1,7 @@ +export * from './opencrvs.module'; +export * from './opencrvs.service'; +export { + BirthRegistrationMapper, + BirthRegistrationFormData, +} from './birth-registration.mapper'; +export * from './types'; diff --git a/src/opencrvs/opencrvs.module.ts b/src/opencrvs/opencrvs.module.ts new file mode 100644 index 0000000..c944d50 --- /dev/null +++ b/src/opencrvs/opencrvs.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { OpenCRVSService } from './opencrvs.service'; +import { BirthRegistrationMapper } from './birth-registration.mapper'; + +@Module({ + imports: [ConfigModule], + providers: [OpenCRVSService, BirthRegistrationMapper], + exports: [OpenCRVSService, BirthRegistrationMapper], +}) +export class OpenCRVSModule {} diff --git a/src/opencrvs/opencrvs.service.ts b/src/opencrvs/opencrvs.service.ts new file mode 100644 index 0000000..2408417 --- /dev/null +++ b/src/opencrvs/opencrvs.service.ts @@ -0,0 +1,336 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { v4 as uuidv4 } from 'uuid'; +import { + TokenResponse, + CreateEventRequest, + CreateEventResponse, + NotifyRequest, + NotifyResponse, + BirthDeclaration, + LocationBundle, + LocationType, +} from './types'; + +@Injectable() +export class OpenCRVSService { + private readonly logger = new Logger(OpenCRVSService.name); + + private readonly authBaseUrl: string; + private readonly eventsBaseUrl: string; + private readonly locationsBaseUrl: string; + private readonly clientId: string; + private readonly clientSecret: string; + + // Cache for access token with expiry + private accessToken: string | null = null; + private tokenExpiry: Date | null = null; + + // Cache for location lookups to avoid repeated API calls + private readonly locationCache: Map = new Map(); + + constructor(private readonly configService: ConfigService) { + const isLocalhost = this.configService.get('opencrvs.localhost'); + + if (isLocalhost) { + this.authBaseUrl = 'http://localhost:4040'; + this.eventsBaseUrl = 'http://localhost:3000'; + this.locationsBaseUrl = 'http://localhost:7070'; + } else { + this.authBaseUrl = this.configService.get( + 'opencrvs.authBaseUrl', + 'https://auth.barbados-qa.opencrvs.org', + ); + this.eventsBaseUrl = this.configService.get( + 'opencrvs.eventsBaseUrl', + 'https://register.barbados-qa.opencrvs.org', + ); + this.locationsBaseUrl = this.configService.get( + 'opencrvs.locationsBaseUrl', + 'https://gateway.barbados-qa.opencrvs.org', + ); + } + + this.clientId = this.configService.get('opencrvs.clientId', ''); + this.clientSecret = this.configService.get( + 'opencrvs.clientSecret', + '', + ); + + this.logger.log( + `OpenCRVS service initialized - Auth: ${this.authBaseUrl}, Events: ${this.eventsBaseUrl}`, + ); + } + + /** + * Get an access token from the OpenCRVS auth service + * Uses caching to avoid unnecessary token requests + */ + async getAccessToken(): Promise { + // Return cached token if still valid (with 5 minute buffer) + if ( + this.accessToken && + this.tokenExpiry && + new Date() < new Date(this.tokenExpiry.getTime() - 5 * 60 * 1000) + ) { + return this.accessToken; + } + + if (!this.clientId || !this.clientSecret) { + throw new Error( + 'OpenCRVS CLIENT_ID or CLIENT_SECRET not configured in environment', + ); + } + + const url = new URL('/token', this.authBaseUrl); + url.searchParams.set('client_id', this.clientId); + url.searchParams.set('client_secret', this.clientSecret); + url.searchParams.set('grant_type', 'client_credentials'); + + this.logger.log(`Requesting OpenCRVS access token from: ${url.host}`); + + const res = await fetch(url.toString(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!res.ok) { + const errorText = await res.text(); + this.logger.error(`Token request failed: ${res.status} ${errorText}`); + throw new Error(`OpenCRVS token request failed: ${res.status}`); + } + + const data = (await res.json()) as TokenResponse; + + if (!data.access_token) { + throw new Error('OpenCRVS token response missing access_token'); + } + + this.accessToken = data.access_token; + + // Set expiry based on response or default to 1 hour + const expiresIn = data.expires_in ?? 3600; + this.tokenExpiry = new Date(Date.now() + expiresIn * 1000); + + this.logger.log('OpenCRVS access token obtained successfully'); + return this.accessToken; + } + + /** + * Create a new birth event in OpenCRVS + */ + async createBirthEvent(transactionId?: string): Promise { + const accessToken = await this.getAccessToken(); + + const payload: CreateEventRequest = { + type: 'birth', + transactionId: transactionId ?? uuidv4(), + dateOfEvent: { fieldId: 'child.dob' }, + }; + + this.logger.log( + `Creating birth event with transactionId: ${payload.transactionId}`, + ); + + const res = await fetch(`${this.eventsBaseUrl}/api/events/events`, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + + if (!res.ok) { + const errorText = await res.text(); + this.logger.error(`Create event failed: ${res.status} ${errorText}`); + throw new Error(`Create birth event failed: ${res.status}`); + } + + const data = (await res.json()) as CreateEventResponse; + + if (!data.id) { + throw new Error('Create event response missing id'); + } + + this.logger.log(`Birth event created: ${data.id} (${data.trackingId})`); + return data; + } + + /** + * Submit a birth notification to OpenCRVS + */ + async notifyBirthEvent( + eventId: string, + declaration: BirthDeclaration, + createdAtLocation: string, + annotation?: Record, + ): Promise { + const accessToken = await this.getAccessToken(); + + const payload: NotifyRequest = { + eventId, + transactionId: uuidv4(), + declaration, + annotation: annotation ?? {}, + createdAtLocation, + type: 'NOTIFY', + }; + + this.logger.log(`Notifying birth event: ${eventId}`); + + const res = await fetch( + `${this.eventsBaseUrl}/api/events/events/notifications`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }, + ); + + if (!res.ok) { + const errorText = await res.text(); + this.logger.error(`Notify event failed: ${res.status} ${errorText}`); + return { + success: false, + error: `Notification failed: ${res.status} - ${errorText}`, + }; + } + + const responseData = await res.json(); + this.logger.log(`Birth notification submitted for event: ${eventId}`); + + return { + success: true, + eventId, + ...responseData, + }; + } + + /** + * Look up a location ID by name and type from OpenCRVS + */ + async getLocationIdByName( + locationName: string, + locationType: LocationType, + ): Promise { + const cacheKey = `${locationType}:${locationName}`; + + // Check cache first + const cachedId = this.locationCache.get(cacheKey); + if (cachedId) { + return cachedId; + } + + const url = `${this.locationsBaseUrl}/location?type=${locationType}`; + + this.logger.log( + `Looking up ${locationType} location: "${locationName}" from ${url}`, + ); + + const res = await fetch(url); + + if (!res.ok) { + const errorText = await res.text(); + this.logger.error( + `Failed to fetch locations: ${res.status} ${errorText}`, + ); + throw new Error(`Failed to fetch locations: ${res.status}`); + } + + const data = (await res.json()) as LocationBundle; + + const match = data.entry?.find((e) => e.resource?.name === locationName); + + if (!match?.resource?.id) { + throw new Error( + `OpenCRVS location not found: "${locationName}" (type: ${locationType})`, + ); + } + + // Cache the result + this.locationCache.set(cacheKey, match.resource.id); + + this.logger.log( + `Found location: "${locationName}" -> ${match.resource.id}`, + ); + + return match.resource.id; + } + + /** + * Register a birth by creating an event and submitting a notification + * This is the main method that orchestrates the full registration flow + */ + async registerBirth( + declaration: BirthDeclaration, + officeId: string, + annotation?: Record, + ): Promise<{ + success: boolean; + eventId?: string; + trackingId?: string; + transactionId?: string; + error?: string; + }> { + try { + // Step 1: Create the event + const transactionId = uuidv4(); + const eventResponse = await this.createBirthEvent(transactionId); + + // Step 2: Submit the notification + const notifyResponse = await this.notifyBirthEvent( + eventResponse.id, + declaration, + officeId, + annotation, + ); + + if (!notifyResponse.success) { + return { + success: false, + eventId: eventResponse.id, + error: notifyResponse.error, + }; + } + + return { + success: true, + eventId: eventResponse.id, + trackingId: eventResponse.trackingId, + transactionId, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error occurred'; + this.logger.error(`Birth registration failed: ${errorMessage}`); + + return { + success: false, + error: errorMessage, + }; + } + } + + /** + * Clear the location cache (useful if locations are updated) + */ + clearLocationCache(): void { + this.locationCache.clear(); + this.logger.log('Location cache cleared'); + } + + /** + * Clear the access token cache (useful for testing or token refresh) + */ + clearTokenCache(): void { + this.accessToken = null; + this.tokenExpiry = null; + this.logger.log('Token cache cleared'); + } +} diff --git a/src/opencrvs/types/index.ts b/src/opencrvs/types/index.ts new file mode 100644 index 0000000..3077f3e --- /dev/null +++ b/src/opencrvs/types/index.ts @@ -0,0 +1 @@ +export * from './opencrvs.types'; diff --git a/src/opencrvs/types/opencrvs.types.ts b/src/opencrvs/types/opencrvs.types.ts new file mode 100644 index 0000000..e3d3f26 --- /dev/null +++ b/src/opencrvs/types/opencrvs.types.ts @@ -0,0 +1,239 @@ +/** + * OpenCRVS integration types for Barbados Civil Registration + * Based on the OpenCRVS Barbados QA API format + */ + +// ============================================================================ +// Authentication Types +// ============================================================================ + +export type TokenResponse = { + access_token: string; + token_type: string; + expires_in?: number; +}; + +// ============================================================================ +// Event Types +// ============================================================================ + +export type CreateEventRequest = { + type: 'birth' | 'death' | 'marriage'; + transactionId: string; + dateOfEvent: { fieldId: string }; +}; + +export type CreateEventResponse = { + id: string; + type: string; + createdAt: string; + updatedAt: string; + trackingId: string; + actions: Array; +}; + +// ============================================================================ +// Common Building Blocks +// ============================================================================ + +export type Gender = 'male' | 'female' | 'unknown'; + +export type PlaceOfBirth = 'HEALTH_FACILITY' | 'PRIVATE_HOME' | 'OTHER'; + +export type BirthType = 'SINGLE' | 'TWIN' | 'TRIPLET' | 'QUADRUPLET_OR_MORE'; + +export type AttendantAtBirth = + | 'DOCTOR' + | 'MIDWIFE' + | 'NURSE' + | 'RELATIVE' + | 'NONE' + | 'OTHER'; + +export type MaritalStatus = + | 'SINGLE' + | 'MARRIED' + | 'WIDOWED' + | 'DIVORCED' + | 'SEPARATED' + | 'NOT_STATED'; + +export type InformantRelation = + | 'FATHER' + | 'MOTHER' + | 'GRANDFATHER' + | 'GRANDMOTHER' + | 'BROTHER' + | 'SISTER' + | 'LEGAL_GUARDIAN' + | 'OTHER'; + +export type IdType = + | 'NATIONAL_ID' + | 'PASSPORT' + | 'BIRTH_REGISTRATION_NUMBER' + | 'NATIONAL_REGISTRATION_NUMBER' + | 'NONE'; + +/** + * Address structure matching OpenCRVS API format + * Uses streetLevelDetails.street instead of direct street field + */ +export type DomesticAddress = { + addressType: 'DOMESTIC'; + country: 'BRB' | string; + administrativeArea: string; // parish ID + streetLevelDetails?: { + street?: string; + }; +}; + +export type PersonName = { + firstname: string; + surname: string; +}; + +/** + * Age reference structure used by OpenCRVS + * Age is calculated relative to a date field (e.g., child's DOB) + */ +export type AgeReference = { + age: number; + asOfDateRef: string; // Reference to date field, e.g., "child.dob" +}; + +// ============================================================================ +// Birth Declaration Types +// ============================================================================ + +/** + * Birth declaration payload matching the OpenCRVS Barbados QA API format + * Based on the Postman collection structure + */ +export type BirthDeclaration = { + // Child information (required) + 'child.name': PersonName; + 'child.gender': Gender; + 'child.dob': string; // YYYY-MM-DD + 'child.placeOfBirth': PlaceOfBirth; + 'child.birthLocation'?: string; // Hospital/facility ID when placeOfBirth = HEALTH_FACILITY + 'child.birthLocation.privateHome'?: DomesticAddress; // Address for private home births + 'child.birthType'?: BirthType; + 'child.attendantAtBirth'?: AttendantAtBirth; + 'child.reason'?: string; // Reason for delayed registration + + // Mother information + 'mother.detailsNotAvailable'?: boolean; + 'mother.reason'?: string; + 'mother.name'?: PersonName; + 'mother.age'?: AgeReference; + 'mother.maritalStatus'?: MaritalStatus; + 'mother.nationality'?: string; + 'mother.idType'?: IdType; + 'mother.nid'?: string; + 'mother.passport'?: string; + 'mother.brn'?: string; + 'mother.address'?: DomesticAddress; + 'mother.occupation'?: string; + 'mother.bornAlive'?: number + 'mother.stillborn'?: number; + 'mother.stillAlive'?: number; + + // Informant information + 'informant.relation'?: 'PARENT' | InformantRelation; + 'informant.parentsMarried'?: 'YES' | 'NO'; + 'informant.phoneNo'?: string; + + // Father information + 'father.detailsNotAvailable'?: boolean; + 'father.reason'?: string; + 'father.name'?: PersonName; + 'father.age'?: AgeReference; + 'father.nationality'?: string; + 'father.idType'?: IdType; + 'father.nid'?: string; + 'father.passport'?: string; + 'father.brn'?: string; + 'father.occupation'?: string; + 'father.addressSameAs'?: boolean; // true to use mother's address + 'father.address'?: DomesticAddress; +}; + +// ============================================================================ +// Notification Types +// ============================================================================ + +export type NotifyRequest = { + eventId: string; + transactionId: string; + declaration: BirthDeclaration; + annotation: Record; + createdAtLocation: string; + type: 'NOTIFY'; +}; + +export type NotifyResponse = { + success: boolean; + eventId?: string; + trackingId?: string; + error?: string; +}; + +// ============================================================================ +// Location Types +// ============================================================================ + +export type LocationBundle = { + entry?: Array<{ + resource?: { + id?: string; + name?: string; + }; + }>; +}; + +export type LocationType = + | 'CRVS_OFFICE' + | 'HEALTH_FACILITY' + | 'ADMIN_STRUCTURE'; + +// ============================================================================ +// Service Configuration Types +// ============================================================================ + +export type OpenCRVSConfig = { + authBaseUrl: string; + eventsBaseUrl: string; + locationsBaseUrl: string; + clientId: string; + clientSecret: string; + defaultOffice?: string; + defaultHealthFacility?: string; + defaultParish?: string; +}; + +// ============================================================================ +// Processor Configuration Types +// ============================================================================ + +export type OpenCRVSProcessorConfig = { + eventType: 'birth'; + officeId?: string; // Location ID or use mapping + officeName?: string; // Will be resolved to ID + healthFacilityId?: string; // Location ID or use mapping + healthFacilityName?: string; // Will be resolved to ID + parishId?: string; // Location ID or use mapping + parishName?: string; // Will be resolved to ID +}; + +// ============================================================================ +// Result Types +// ============================================================================ + +export type OpenCRVSProcessorResult = { + success: boolean; + eventId?: string; + trackingId?: string; + transactionId?: string; + error?: string; +}; diff --git a/src/processors/implementations/opencrvs.processor.ts b/src/processors/implementations/opencrvs.processor.ts new file mode 100644 index 0000000..e289e94 --- /dev/null +++ b/src/processors/implementations/opencrvs.processor.ts @@ -0,0 +1,171 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { IProcessor, ProcessorContext } from '../interfaces'; +import { OpenCRVSService, BirthRegistrationMapper } from '../../opencrvs'; +import { + OpenCRVSProcessorConfig, + OpenCRVSProcessorResult, +} from '../../opencrvs/types'; + +/** + * Processor for integrating birth registration form submissions with OpenCRVS + * + * This processor handles: + * 1. Resolving location IDs (office, health facility, parish) from names or IDs + * 2. Mapping form data to OpenCRVS BirthDeclaration format + * 3. Creating the birth event in OpenCRVS + * 4. Submitting the birth notification + * + * Note: Informant fields are not required per OpenCRVS Barbados configuration. + */ +@Injectable() +export class OpenCRVSProcessor implements IProcessor { + readonly type = 'opencrvs'; + private readonly logger = new Logger(OpenCRVSProcessor.name); + + constructor( + private readonly openCRVSService: OpenCRVSService, + private readonly birthMapper: BirthRegistrationMapper, + ) {} + + async execute( + config: Record, + context: ProcessorContext, + ): Promise { + this.logger.log( + `Executing OpenCRVS processor for form: ${context.formId}, submission: ${context.submissionId}`, + ); + + const processorConfig = config as unknown as OpenCRVSProcessorConfig; + + try { + // Validate event type + if (processorConfig.eventType !== 'birth') { + throw new Error( + `Unsupported event type: ${processorConfig.eventType}. Only 'birth' is currently supported.`, + ); + } + + // Resolve location IDs + const { officeId, healthFacilityId, parishId } = + await this.resolveLocations(processorConfig, context.data); + + // Map form data to OpenCRVS declaration format + // Note: Informant fields are not required per OpenCRVS Barbados configuration + const declaration = this.birthMapper.mapToBirthDeclaration(context.data, { + parishId, + healthFacilityId, + defaultNationality: 'BRB', + }); + + // Create annotation (empty - OpenCRVS rejects custom fields) + const annotation = this.birthMapper.createAnnotation(context.data); + + // Register the birth with OpenCRVS + const result = await this.openCRVSService.registerBirth( + declaration, + officeId, + annotation, + ); + + if (result.success) { + this.logger.log( + `Birth registration successful for ${context.submissionId}: eventId=${result.eventId}, trackingId=${result.trackingId}`, + ); + } else { + this.logger.error( + `Birth registration failed for ${context.submissionId}: ${result.error}`, + ); + } + + return result; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + this.logger.error( + `OpenCRVS processor failed for ${context.submissionId}: ${errorMessage}`, + error instanceof Error ? error.stack : undefined, + ); + + return { + success: false, + error: errorMessage, + }; + } + } + + /** + * Resolve location IDs from configuration + * Supports both direct IDs and name-based lookups + */ + private async resolveLocations( + config: OpenCRVSProcessorConfig, + formData: Record, + ): Promise<{ + officeId: string; + healthFacilityId?: string; + parishId: string; + }> { + // Resolve CRVS Office ID + let officeId = config.officeId; + if (!officeId && config.officeName) { + officeId = await this.openCRVSService.getLocationIdByName( + config.officeName, + 'CRVS_OFFICE', + ); + } + if (!officeId) { + // Default to main Registration Department + officeId = await this.openCRVSService.getLocationIdByName( + 'Registration Department Records Branch', + 'CRVS_OFFICE', + ); + } + + // Resolve Health Facility ID (optional - only needed if birth.placeOfBirth is health-facility) + let healthFacilityId = config.healthFacilityId; + if (!healthFacilityId && config.healthFacilityName) { + healthFacilityId = await this.openCRVSService.getLocationIdByName( + config.healthFacilityName, + 'HEALTH_FACILITY', + ); + } + + // Resolve Parish ID from config first + let parishId = config.parishId; + if (!parishId && config.parishName) { + parishId = await this.openCRVSService.getLocationIdByName( + config.parishName, + 'ADMIN_STRUCTURE', + ); + } + + // Try to get parish from mother's parish field in form data + if (!parishId) { + const motherData = formData.mother as { parish?: string } | undefined; + const motherParish = motherData?.parish; + + if (motherParish) { + try { + parishId = await this.openCRVSService.getLocationIdByName( + motherParish, + 'ADMIN_STRUCTURE', + ); + } catch { + this.logger.warn( + `Could not resolve parish from mother's address: ${motherParish}`, + ); + } + } + } + + // Default parish if still not resolved + if (!parishId) { + parishId = await this.openCRVSService.getLocationIdByName( + 'Christ Church', + 'ADMIN_STRUCTURE', + ); + } + + return { officeId, healthFacilityId, parishId }; + } +} diff --git a/src/processors/processor-pipeline.service.ts b/src/processors/processor-pipeline.service.ts index 12f7a8a..25ea174 100644 --- a/src/processors/processor-pipeline.service.ts +++ b/src/processors/processor-pipeline.service.ts @@ -18,11 +18,13 @@ export class ProcessorPipelineService { async execute( processorConfigs: ProcessorConfig[], context: ProcessorContext, - ): Promise { + ): Promise> { this.logger.log( `Executing ${processorConfigs.length} processors for form: ${context.formId}`, ); + const results = new Map(); + // Execute all processors in parallel const promises = processorConfigs.map(async (config) => { const processor = this.processors.get(config.type); @@ -36,8 +38,9 @@ export class ProcessorPipelineService { try { this.logger.log(`Executing processor: ${config.type}`); - await processor.execute(config.config, context); + const result = await processor.execute(config.config, context); this.logger.log(`Processor completed: ${config.type}`); + return { type: config.type, result }; } catch (error) { this.logger.error( `Processor ${config.type} failed: ${error.message}`, @@ -47,8 +50,15 @@ export class ProcessorPipelineService { } }); - await Promise.all(promises); + const processorResults = await Promise.all(promises); + + // Collect results by processor type + for (const { type, result } of processorResults) { + results.set(type, result); + } + this.logger.log('All processors completed successfully'); + return results; } /** diff --git a/src/processors/processors.module.ts b/src/processors/processors.module.ts index 9e98dbe..9653f82 100644 --- a/src/processors/processors.module.ts +++ b/src/processors/processors.module.ts @@ -3,21 +3,25 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { ProcessorPipelineService } from './processor-pipeline.service'; import { EmailModule } from '../email/email.module'; import { PaymentsModule } from '../payments/payments.module'; +import { OpenCRVSModule } from '../opencrvs/opencrvs.module'; import { Payment, FormSubmissionPayment } from '../database/entities'; import { EmailProcessor } from './implementations/email.processor'; import { PaymentProcessor } from './implementations/payment.processor'; +import { OpenCRVSProcessor } from './implementations/opencrvs.processor'; import { EZPayService, DepartmentMappingService } from '../payments'; @Module({ imports: [ EmailModule, PaymentsModule, + OpenCRVSModule, TypeOrmModule.forFeature([Payment, FormSubmissionPayment]), ], providers: [ ProcessorPipelineService, EmailProcessor, PaymentProcessor, + OpenCRVSProcessor, EZPayService, DepartmentMappingService, ], @@ -28,9 +32,11 @@ export class ProcessorsModule { private readonly pipelineService: ProcessorPipelineService, private readonly emailProcessor: EmailProcessor, private readonly paymentProcessor: PaymentProcessor, + private readonly openCRVSProcessor: OpenCRVSProcessor, ) { // Register all processors this.pipelineService.registerProcessor(this.emailProcessor); this.pipelineService.registerProcessor(this.paymentProcessor); + this.pipelineService.registerProcessor(this.openCRVSProcessor); } } From dd2876ba03408005cc26bcc3b0c1b67113545cea Mon Sep 17 00:00:00 2001 From: Shannon Clarke Date: Tue, 20 Jan 2026 15:41:21 -0400 Subject: [PATCH 11/49] Revert changes to schemas/register-birth-form.json from commit 4ab1178 --- schemas/register-birth-form.json | 232 +++++++++---------------------- 1 file changed, 66 insertions(+), 166 deletions(-) diff --git a/schemas/register-birth-form.json b/schemas/register-birth-form.json index 582f48b..7b563dd 100644 --- a/schemas/register-birth-form.json +++ b/schemas/register-birth-form.json @@ -53,49 +53,28 @@ } }, { - "name": "age", + "name": "hadOtherSurname", "type": "string", - "required": { - "field": "idNumber", - "value": [null, ""], - "message": "Age is required when no ID number is provided" - }, + "required": false, "validations": { - "condition": { - "field": "idNumber", - "operator": "notIn", - "value": [null, ""], - "then": { - "min": 0, - "message": "Age not required" - }, - "else": { - "min": 1, - "message": "Age is required" - } - } + "regex": "^(yes|no)$" } }, { - "name": "parish", + "name": "otherSurname", "type": "string", - "required": true, + "required": false, "validations": { - "min": 2, - "max": 500 + "max": 100 } }, { - "name": "addressLine1", - "type": "string", - "required": true, - "validations": { - "min": 2, - "max": 500 - } + "name": "dateOfBirth", + "type": "date", + "required": false }, { - "name": "addressLine2", + "name": "address", "type": "string", "required": false, "validations": { @@ -103,7 +82,7 @@ } }, { - "name": "idNumber", + "name": "nationalRegistrationNumber", "type": "string", "required": false, "validations": { @@ -120,110 +99,20 @@ } }, { - "name": "occupation", + "name": "passportPlaceOfIssue", "type": "string", "required": false, "validations": { "max": 100 } - } - ] - }, - { - "name": "birth", - "type": "object", - "required": true, - "fields": [ - { - "name": "placeOfBirth", - "type": "string", - "label": "Where did the birth take place?", - "required": true, - "validations": { - "regex": "^(health-facility|residential|other)$", - "message": "Must select an option" - } - }, - { - "name": "parish", - "type": "string", - "label": "Parish", - "required": { - "field": "birth.placeOfBirth", - "value": ["residential", "other"], - "message": "Parish is required for residential/other birth locations" - }, - "validations": { - "condition": { - "field": "birth.placeOfBirth", - "operator": "in", - "value": ["residential", "other"], - "then": { - "regex": "^(christ-church|st-andrew|st-george|st-james|st-john|st-joseph|st-lucy|st-michael|st-peter|st-philip|st-thomas)$", - "message": "Must select a valid parish" - } - } - } - }, - { - "name": "streetAddress", - "type": "string", - "label": "Street Address", - "required": { - "field": "birth.placeOfBirth", - "value": ["residential", "other"], - "message": "Street address is required for residential/other birth locations" - }, - "validations": { - "condition": { - "field": "birth.placeOfBirth", - "operator": "in", - "value": ["residential", "other"], - "then": { - "min": 5, - "max": 200, - "message": "Address must be at least 5 characters" - } - } - } }, { - "name": "numberOfBirths", - "type": "string", - "label": "How many births do you need to register?", - "required": true, - "validations": { - "regex": "^(single|twins|triplets|more-than-triplets)$", - "message": "Must select an option" - } - }, - { - "name": "attendantAtBirth", + "name": "occupation", "type": "string", - "label": "Attendant at birth", - "required": true, + "required": false, "validations": { - "regex": "^(doctor|midwife|nurse|relative|none)$", - "message": "Must select an option" + "max": 100 } - }, - { - "name": "liveBorn", - "type": "string", - "label": "Live born", - "required": false - }, - { - "name": "stillBorn", - "type": "string", - "label": "Still born", - "required": false - }, - { - "name": "totalStillAlive", - "type": "string", - "label": "Total still alive", - "required": false } ] }, @@ -259,54 +148,58 @@ } }, { - "name": "maidenSurname", + "name": "hadOtherSurname", "type": "string", "required": false, "validations": { - "max": 100 + "regex": "^(yes|no)$" } }, { - "name": "parish", + "name": "otherSurname", "type": "string", - "required": true, + "required": false, "validations": { - "min": 2, - "max": 500 + "max": 100 } }, { - "name": "addressLine1", + "name": "dateOfBirth", + "type": "date", + "required": true + }, + { + "name": "address", "type": "string", "required": true, "validations": { - "min": 2, + "min": 5, "max": 500 } }, { - "name": "addressLine2", + "name": "nationalRegistrationNumber", "type": "string", "required": false, "validations": { - "max": 500 + "regex": "^\\d{6}-\\d{4}$", + "message": "Must be in format XXXXXX-XXXX" } }, { - "name": "idNumber", + "name": "passportNumber", "type": "string", "required": false, "validations": { - "regex": "^\\d{6}-\\d{4}$", - "message": "Must be in format XXXXXX-XXXX" + "max": 50 } }, { - "name": "passportNumber", + "name": "passportPlaceOfIssue", "type": "string", "required": false, "validations": { - "max": 50 + "max": 100 } }, { @@ -326,7 +219,7 @@ "required": true, "fields": [ { - "name": "firstName", + "name": "firstNames", "type": "string", "required": true, "validations": { @@ -335,7 +228,7 @@ } }, { - "name": "middleName", + "name": "middleNames", "type": "string", "required": false, "validations": { @@ -361,44 +254,51 @@ "type": "string", "required": true, "validations": { - "regex": "^(male|female)$", - "message": "Must be 'male' or 'female'" + "regex": "^(Male|Female)$", + "message": "Must be 'Male' or 'Female'" } - } - ] - }, - { - "name": "order", - "type": "object", - "required": true, - "fields": [ + }, { - "name": "numberOfCopies", - "type": "number", - "label": "Number of copies", + "name": "parishOfBirth", + "type": "string", "required": true, "validations": { "min": 1, - "max": 10, - "message": "You must order at least 1 copy and maximum 10 copies" + "max": 100 } } ] - } - ], - "processors": [ + }, { - "type": "opencrvs", - "config": { - "eventType": "birth", - "officeName": "Registration District A" + "name": "numberOfCertificates", + "type": "number", + "required": true, + "validations": { + "min": 0, + "max": 20 } }, + { + "name": "email", + "type": "email", + "required": true + }, + { + "name": "phoneNumber", + "type": "string", + "required": true, + "validations": { + "min": 7, + "max": 20 + } + } + ], + "processors": [ { "type": "email", "config": { "to": "{{db:register-birth-form:admin_email}}", - "subject": "New Birth Registration - {{formData.child.firstName}} {{formData.child.lastName}}", + "subject": "New Birth Registration - {{formData.child.firstNames}} {{formData.child.lastName}}", "template": "birth-registration" } }, From 8d94892e57760a29b399d801660fa6aeb626a1ae Mon Sep 17 00:00:00 2001 From: Akinola Raphael <54055273+Ethical-Ralph@users.noreply.github.com> Date: Tue, 20 Jan 2026 20:53:35 +0100 Subject: [PATCH 12/49] fix: complete email template audit and add applicant confirmation emails (#60) --- schemas/community-sports-programme.json | 8 + schemas/conductor-licence.json | 8 + schemas/fire-service-inspection.json | 10 +- schemas/get-birth-certificate.json | 8 + schemas/get-death-certificate.json | 8 + schemas/get-marriage-certificate.json | 8 + schemas/jobstart-plus-programme.json | 8 + schemas/permission-to-remove-tree.json | 8 + schemas/post-office-redirection-business.json | 8 + schemas/post-office-redirection-deceased.json | 8 + .../post-office-redirection-individual.json | 8 + schemas/primary-school-textbook-grant.json | 10 +- schemas/reserve-company-name.json | 10 +- schemas/reserve-society-name.json | 8 + schemas/sell-goods-services-beach-park.json | 8 + .../templates/apply-for-conductor-licence.hbs | 177 +++++---- .../templates/birth-certificate-receipt.hbs | 54 +++ .../community-sports-programme-receipt.hbs | 54 +++ .../community-sports-registration.hbs | 4 + .../templates/conductor-licence-receipt.hbs | 54 +++ .../templates/death-certificate-receipt.hbs | 54 +++ .../fire-service-inspection-receipt.hbs | 54 +++ .../jobstart-plus-programme-receipt.hbs | 54 +++ .../templates/jobstart-plus-programme.hbs | 299 ++++++++++++-- .../marriage-certificate-receipt.hbs | 54 +++ .../permission-to-remove-tree-receipt.hbs | 54 +++ ...st-office-redirection-business-receipt.hbs | 119 ++++++ ...st-office-redirection-deceased-receipt.hbs | 111 ++++++ ...-office-redirection-individual-receipt.hbs | 54 +++ .../post-office-redirection-notice.hbs | 370 ++++++++++++------ .../primary-school-textbook-grant-receipt.hbs | 151 ++----- .../primary-school-textbook-grant.hbs | 231 ++++++----- .../request-a-fire-service-inspection.hbs | 100 +++++ .../reserve-company-name-receipt.hbs | 54 +++ src/email/templates/reserve-company-name.hbs | 69 ++-- .../reserve-society-name-receipt.hbs | 54 +++ .../sell-goods-services-beach-park-notice.hbs | 155 +++++++- ...sell-goods-services-beach-park-receipt.hbs | 54 +++ 38 files changed, 2036 insertions(+), 524 deletions(-) create mode 100644 src/email/templates/birth-certificate-receipt.hbs create mode 100644 src/email/templates/community-sports-programme-receipt.hbs create mode 100644 src/email/templates/conductor-licence-receipt.hbs create mode 100644 src/email/templates/death-certificate-receipt.hbs create mode 100644 src/email/templates/fire-service-inspection-receipt.hbs create mode 100644 src/email/templates/jobstart-plus-programme-receipt.hbs create mode 100644 src/email/templates/marriage-certificate-receipt.hbs create mode 100644 src/email/templates/permission-to-remove-tree-receipt.hbs create mode 100644 src/email/templates/post-office-redirection-business-receipt.hbs create mode 100644 src/email/templates/post-office-redirection-deceased-receipt.hbs create mode 100644 src/email/templates/post-office-redirection-individual-receipt.hbs create mode 100644 src/email/templates/request-a-fire-service-inspection.hbs create mode 100644 src/email/templates/reserve-company-name-receipt.hbs create mode 100644 src/email/templates/reserve-society-name-receipt.hbs create mode 100644 src/email/templates/sell-goods-services-beach-park-receipt.hbs diff --git a/schemas/community-sports-programme.json b/schemas/community-sports-programme.json index 1d90381..f1baa36 100644 --- a/schemas/community-sports-programme.json +++ b/schemas/community-sports-programme.json @@ -298,6 +298,14 @@ "subject": "New Community Sports Programme Registration - {{formData.applicant.firstName}} {{formData.applicant.lastName}}", "template": "community-sports-registration" } + }, + { + "type": "email", + "config": { + "to": "{{formData.email}}", + "subject": "Community Sports Programme Registration - Submission Received", + "template": "community-sports-programme-receipt" + } } ] } diff --git a/schemas/conductor-licence.json b/schemas/conductor-licence.json index 89e447b..fe9452c 100644 --- a/schemas/conductor-licence.json +++ b/schemas/conductor-licence.json @@ -244,6 +244,14 @@ "subject": "Apply for conductor licence - {{formData.applicant.firstName}} {{formData.applicant.lastName}}", "template": "apply-for-conductor-licence" } + }, + { + "type": "email", + "config": { + "to": "{{formData.contactDetails.email}}", + "subject": "Conductor Licence Application - Submission Received", + "template": "conductor-licence-receipt" + } } ] } diff --git a/schemas/fire-service-inspection.json b/schemas/fire-service-inspection.json index d74cfb0..f50b2e2 100644 --- a/schemas/fire-service-inspection.json +++ b/schemas/fire-service-inspection.json @@ -118,9 +118,17 @@ "type": "email", "config": { "to": "{{db:request-a-fire-service-inspection:admin_email}}", - "subject": "New Birth Certificate Application - {{formData.applicant.firstName}} {{formData.applicant.lastName}}", + "subject": "New Fire Service Inspection Request - {{formData.applicant.firstName}} {{formData.applicant.lastName}}", "template": "request-a-fire-service-inspection" } + }, + { + "type": "email", + "config": { + "to": "{{formData.applicant.email}}", + "subject": "Fire Service Inspection Request - Submission Received", + "template": "fire-service-inspection-receipt" + } } ] } diff --git a/schemas/get-birth-certificate.json b/schemas/get-birth-certificate.json index 90b4a78..0e94081 100644 --- a/schemas/get-birth-certificate.json +++ b/schemas/get-birth-certificate.json @@ -513,6 +513,14 @@ "subject": "New Birth Certificate Application - {{formData.applicant.firstName}} {{formData.applicant.lastName}}", "template": "birth-certificate" } + }, + { + "type": "email", + "config": { + "to": "{{formData.applicant.email}}", + "subject": "Birth Certificate Application - Submission Received", + "template": "birth-certificate-receipt" + } } ] } diff --git a/schemas/get-death-certificate.json b/schemas/get-death-certificate.json index 7c345b8..31d367a 100644 --- a/schemas/get-death-certificate.json +++ b/schemas/get-death-certificate.json @@ -269,6 +269,14 @@ "subject": "New Death Certificate Application - {{formData.applicant.firstName}} {{formData.applicant.lastName}}", "template": "death-certificate" } + }, + { + "type": "email", + "config": { + "to": "{{formData.applicant.email}}", + "subject": "Death Certificate Application - Submission Received", + "template": "death-certificate-receipt" + } } ] } diff --git a/schemas/get-marriage-certificate.json b/schemas/get-marriage-certificate.json index 51e0deb..680208c 100644 --- a/schemas/get-marriage-certificate.json +++ b/schemas/get-marriage-certificate.json @@ -343,6 +343,14 @@ "subject": "New Marriage Certificate Application - {{formData.applicant.firstName}} {{formData.applicant.lastName}}", "template": "marriage-certificate" } + }, + { + "type": "email", + "config": { + "to": "{{formData.applicant.email}}", + "subject": "Marriage Certificate Application - Submission Received", + "template": "marriage-certificate-receipt" + } } ] } diff --git a/schemas/jobstart-plus-programme.json b/schemas/jobstart-plus-programme.json index ef71ed6..296eae7 100644 --- a/schemas/jobstart-plus-programme.json +++ b/schemas/jobstart-plus-programme.json @@ -508,6 +508,14 @@ "subject": "New JobStart Plus Programme Application - {{formData.applicant.firstName}} {{formData.applicant.lastName}}", "template": "jobstart-plus-programme" } + }, + { + "type": "email", + "config": { + "to": "{{formData.contactDetails.email}}", + "subject": "JobStart Plus Programme Application - Submission Received", + "template": "jobstart-plus-programme-receipt" + } } ] } diff --git a/schemas/permission-to-remove-tree.json b/schemas/permission-to-remove-tree.json index c889fd0..ce54949 100644 --- a/schemas/permission-to-remove-tree.json +++ b/schemas/permission-to-remove-tree.json @@ -145,6 +145,14 @@ "subject": "New Request to remove protected tree - {{formData.applicant.firstName}} {{formData.applicant.lastName}}", "template": "permission-to-remove-tree" } + }, + { + "type": "email", + "config": { + "to": "{{formData.applicant.email}}", + "subject": "Permission to Remove Tree - Submission Received", + "template": "permission-to-remove-tree-receipt" + } } ] } diff --git a/schemas/post-office-redirection-business.json b/schemas/post-office-redirection-business.json index bc81c82..fc5b706 100644 --- a/schemas/post-office-redirection-business.json +++ b/schemas/post-office-redirection-business.json @@ -217,6 +217,14 @@ "subject": "New Request to Redirect my business mail - {{formData.applicant.firstName}} {{formData.applicant.lastName}}", "template": "post-office-redirection-notice" } + }, + { + "type": "email", + "config": { + "to": "{{formData.applicant.email}}", + "subject": "Business Mail Redirection - Submission Received", + "template": "post-office-redirection-business-receipt" + } } ] } diff --git a/schemas/post-office-redirection-deceased.json b/schemas/post-office-redirection-deceased.json index bf28cc4..b102bb3 100644 --- a/schemas/post-office-redirection-deceased.json +++ b/schemas/post-office-redirection-deceased.json @@ -263,6 +263,14 @@ "subject": "New Request to redirect mail for a Deceased Person - {{formData.applicant.firstName}} {{formData.applicant.lastName}}", "template": "post-office-redirection-notice" } + }, + { + "type": "email", + "config": { + "to": "{{formData.applicant.email}}", + "subject": "Deceased Mail Redirection - Submission Received", + "template": "post-office-redirection-deceased-receipt" + } } ] } diff --git a/schemas/post-office-redirection-individual.json b/schemas/post-office-redirection-individual.json index ab5e075..cf88f8b 100644 --- a/schemas/post-office-redirection-individual.json +++ b/schemas/post-office-redirection-individual.json @@ -256,6 +256,14 @@ "subject": "New Request to redirect mail for an individual - {{formData.applicant.firstName}} {{formData.applicant.lastName}}", "template": "post-office-redirection-notice" } + }, + { + "type": "email", + "config": { + "to": "{{formData.applicant.email}}", + "subject": "Mail Redirection - Submission Received", + "template": "post-office-redirection-individual-receipt" + } } ] } diff --git a/schemas/primary-school-textbook-grant.json b/schemas/primary-school-textbook-grant.json index b1e1a52..bd1026b 100644 --- a/schemas/primary-school-textbook-grant.json +++ b/schemas/primary-school-textbook-grant.json @@ -193,9 +193,17 @@ "type": "email", "config": { "to": "{{db:primary-school-textbook-grant:admin_email}}", - "subject": "New Textbook Grant Application - {{formData.beneficiaries.firstName}} {{formData.beneficiaries.lastName}}", + "subject": "New Textbook Grant Application - {{formData.applicant.firstName}} {{formData.applicant.lastName}}", "template": "primary-school-textbook-grant" } + }, + { + "type": "email", + "config": { + "to": "{{formData.applicant.email}}", + "subject": "Primary School Textbook Grant - Submission Received", + "template": "primary-school-textbook-grant-receipt" + } } ] } diff --git a/schemas/reserve-company-name.json b/schemas/reserve-company-name.json index 73f80f8..2c3f87f 100644 --- a/schemas/reserve-company-name.json +++ b/schemas/reserve-company-name.json @@ -173,9 +173,17 @@ "type": "email", "config": { "to": "{{db:reserve-company-name:admin_email}}", - "subject": "New Request to Reserve Society Name - {{formData.applicant.firstName}} {{formData.applicant.lastName}}", + "subject": "New Request to Reserve Company Name - {{formData.applicant.firstName}} {{formData.applicant.lastName}}", "template": "reserve-company-name" } + }, + { + "type": "email", + "config": { + "to": "{{formData.applicant.email}}", + "subject": "Reserve Company Name - Submission Received", + "template": "reserve-company-name-receipt" + } } ] } diff --git a/schemas/reserve-society-name.json b/schemas/reserve-society-name.json index 8a24435..6d47f6f 100644 --- a/schemas/reserve-society-name.json +++ b/schemas/reserve-society-name.json @@ -151,6 +151,14 @@ "subject": "New Request to Reserve Society Name - {{formData.applicant.firstName}} {{formData.applicant.lastName}}", "template": "reserve-society-name" } + }, + { + "type": "email", + "config": { + "to": "{{formData.applicant.email}}", + "subject": "Reserve Society Name - Submission Received", + "template": "reserve-society-name-receipt" + } } ] } diff --git a/schemas/sell-goods-services-beach-park.json b/schemas/sell-goods-services-beach-park.json index 5caa9c6..4f32348 100644 --- a/schemas/sell-goods-services-beach-park.json +++ b/schemas/sell-goods-services-beach-park.json @@ -383,6 +383,14 @@ "subject": "New Request to sell good or services at a beach or park - {{formData.applicant.firstName}} {{formData.applicant.lastName}}", "template": "sell-goods-services-beach-park-notice" } + }, + { + "type": "email", + "config": { + "to": "{{formData.applicant.email}}", + "subject": "Sell Goods/Services at Beach or Park - Submission Received", + "template": "sell-goods-services-beach-park-receipt" + } } ] } diff --git a/src/email/templates/apply-for-conductor-licence.hbs b/src/email/templates/apply-for-conductor-licence.hbs index e6842ce..11f934f 100644 --- a/src/email/templates/apply-for-conductor-licence.hbs +++ b/src/email/templates/apply-for-conductor-licence.hbs @@ -12,122 +12,153 @@ .section-title { color: #2c5282; font-size: 18px; font-weight: bold; margin-bottom: 15px; border-bottom: 2px solid #2c5282; padding-bottom: 5px; } .field { margin-bottom: 12px; } .field-label { font-weight: bold; - color: #4a5568; display: inline-block; width: 200px; } .field-value { - color: #1a202c; } .address-comparison { display: flex; gap: 20px; } - .address-box { flex: 1; border: 1px solid #e2e8f0; padding: 15px; - border-radius: 5px; } .old-address { background-color: #fed7d7; } - .new-address { background-color: #c6f6d5; } .footer { margin-top: 30px; - padding: 20px; background-color: #edf2f7; border-radius: 5px; text-align: - center; font-size: 14px; color: #718096; } + color: #4a5568; display: inline-block; width: 250px; } .field-value { + color: #1a202c; } .footer { margin-top: 30px; padding: 20px; + background-color: #edf2f7; border-radius: 5px; text-align: center; + font-size: 14px; color: #718096; } .endorsement-item { background-color: + #f7fafc; padding: 15px; margin: 10px 0; border-left: 3px solid #2c5282; + border-radius: 3px; }
-

📮 Post Office Redirection Notice Request

+

🚌 Conductor Licence Application

-

A new post office redirection notice request has been submitted.

+

A new conductor licence application has been submitted.

-
Personal Information
+
Applicant Information
Name: - {{applicant.title}} - {{applicant.firstName}} - {{applicant.lastName}} + {{applicant.title}} {{applicant.firstName}}{{#if applicant.middleName}} {{applicant.middleName}}{{/if}} {{applicant.lastName}} +
+ {{#if applicant.dateOfBirth}} +
+ Date of Birth: + {{applicant.dateOfBirth}} +
+ {{/if}} +
+ +
+
Contact Details
+
+ Address: + {{contactDetails.addressLine1}}{{#if contactDetails.addressLine2}}, {{contactDetails.addressLine2}}{{/if}} +
+
+ Parish: + {{contactDetails.parish}} +
+ {{#if contactDetails.postalCode}} +
+ Postal Code: + {{contactDetails.postalCode}} +
+ {{/if}} +
+ Email: + {{contactDetails.email}}
- Date of Birth: - {{applicant.dateOfBirth}} + Telephone: + {{contactDetails.telephoneNumber}}
+
+ +
+
Licence History
- ID Number: - {{applicant.idNumber}} + Has Previous Licence: + {{licenceHistory.hasPreviousLicence}}
- {{#if applicant.passportNumber}} + {{#if licenceHistory.licenceNumber}}
- Passport Number: - {{applicant.passportNumber}} + Licence Number: + {{licenceHistory.licenceNumber}}
{{/if}} - {{#if applicant.email}} + {{#if licenceHistory.dateOfIssue}}
- Email: - {{applicant.email}} + Date of Issue: + {{licenceHistory.dateOfIssue}}
{{/if}} -
-
Contact Details
-
-
-
- Address Line 1: - {{contactDetails.addressLine1}} -
- {{#if contactDetails.addressLine2}} -
- Address Line 2: - {{contactDetails.addressLine2}} -
- {{/if}} -
- Parish: - {{contactDetails.parish}} -
- {{#if contactDetails.postalCode}} +
Endorsements
+
+ Has Endorsements: + {{hasEndorsements}} +
+ {{#if endorsementDetails}} + {{#each endorsementDetails}} +
- Postal Code: - {{contactDetails.postalCode}} + Type: + {{this.typeOfEndorsement}}
- {{/if}} - - {{#if contactDetails.email}}
- Email: - {{contactDetails.email}} + Date: + {{this.dateOfEndorsement}}
- {{/if}} - {{#if contactDetails.telephoneNumber}}
- Phone Number: - {{contactDetails.telephoneNumber}} + Duration: + {{this.duration}}
- {{/if}} -
-
+
+ {{/each}} + {{/if}}
-
House Member Information
-
- Member Name: - {{houseMembers.firstName}} - {{houseMembers.lastName}} -
+
Disqualifications
- Member ID Number: - {{houseMembers.idNumber}} + Has Been Disqualified: + {{disqualifications.hasDisqualifications}}
- {{#if houseMembers.addAnother}} + {{#if disqualifications.courtName}} +
+ Court Name: + {{disqualifications.courtName}} +
+ {{/if}} + {{#if disqualifications.reasonForDisqualification}} +
+ Reason: + {{disqualifications.reasonForDisqualification}} +
+ {{/if}} + {{#if disqualifications.dateOfDisqualification}} +
+ Date: + {{disqualifications.dateOfDisqualification}} +
+ {{/if}} + {{#if disqualifications.lengthOfDisqualification}}
- Add Another Member: - {{houseMembers.addAnother}} + Length: + {{disqualifications.lengthOfDisqualification}}
{{/if}}
+
+
Criminal Record
+
+ Has Criminal Convictions: + {{hasCriminalConvictions}} +
+
+
- \ No newline at end of file + diff --git a/src/email/templates/birth-certificate-receipt.hbs b/src/email/templates/birth-certificate-receipt.hbs new file mode 100644 index 0000000..28449c8 --- /dev/null +++ b/src/email/templates/birth-certificate-receipt.hbs @@ -0,0 +1,54 @@ + + + + + + +
+
+ Government of Barbados +
+
+ +
+

Birth Certificate Application - Submission Received

+ +
+

Your Submission ID: {{submissionId}}

+

Please save this ID for your records.

+
+ +
+

What happens next

+

Your application for a birth certificate has been received. We will process your request and contact you with payment instructions and collection details for your certified copy.

+
+ +
+

Need help?

+

If you have questions about your application, please contact the Registration Department at the Supreme Court Complex.

+
+
+ +
+
+

Government of Barbados
+ This is an automated confirmation email. Please do not reply.

+
+
+ + diff --git a/src/email/templates/community-sports-programme-receipt.hbs b/src/email/templates/community-sports-programme-receipt.hbs new file mode 100644 index 0000000..944b9a5 --- /dev/null +++ b/src/email/templates/community-sports-programme-receipt.hbs @@ -0,0 +1,54 @@ + + + + + + +
+
+ Government of Barbados +
+
+ +
+

Community Sports Programme Registration - Submission Received

+ +
+

Your Submission ID: {{submissionId}}

+

Please save this ID for your records.

+
+ +
+

What happens next

+

Your registration for the Community Sports Programme has been received. Our team will review your application and contact you with information about upcoming programmes, schedules, and any required documentation.

+
+ +
+

Need help?

+

If you have questions about your registration, please contact the Ministry of Youth, Sports and Community Empowerment.

+
+
+ +
+
+

Government of Barbados
+ This is an automated confirmation email. Please do not reply.

+
+
+ + diff --git a/src/email/templates/community-sports-registration.hbs b/src/email/templates/community-sports-registration.hbs index e7f8fea..8dcfaa0 100644 --- a/src/email/templates/community-sports-registration.hbs +++ b/src/email/templates/community-sports-registration.hbs @@ -89,6 +89,10 @@
Contact Information
+
+ Email: + {{email}} +
Employment Status: {{employmentStatus}} diff --git a/src/email/templates/conductor-licence-receipt.hbs b/src/email/templates/conductor-licence-receipt.hbs new file mode 100644 index 0000000..412321e --- /dev/null +++ b/src/email/templates/conductor-licence-receipt.hbs @@ -0,0 +1,54 @@ + + + + + + +
+
+ Government of Barbados +
+
+ +
+

Conductor Licence Application - Submission Received

+ +
+

Your Submission ID: {{submissionId}}

+

Please save this ID for your records.

+
+ +
+

What happens next

+

Your conductor licence application has been received and is being processed. You will be contacted regarding any additional documentation required and the next steps for obtaining your licence.

+
+ +
+

Need help?

+

If you have questions about your application, please contact the Ministry of Transport, Works and Water Resources.

+
+
+ +
+
+

Government of Barbados
+ This is an automated confirmation email. Please do not reply.

+
+
+ + diff --git a/src/email/templates/death-certificate-receipt.hbs b/src/email/templates/death-certificate-receipt.hbs new file mode 100644 index 0000000..0a8b020 --- /dev/null +++ b/src/email/templates/death-certificate-receipt.hbs @@ -0,0 +1,54 @@ + + + + + + +
+
+ Government of Barbados +
+
+ +
+

Death Certificate Application - Submission Received

+ +
+

Your Submission ID: {{submissionId}}

+

Please save this ID for your records.

+
+ +
+

What happens next

+

Your application for a death certificate has been received. We will process your request and contact you with payment instructions and collection details for your certified copy.

+
+ +
+

Need help?

+

If you have questions about your application, please contact the Registration Department at the Supreme Court Complex.

+
+
+ +
+
+

Government of Barbados
+ This is an automated confirmation email. Please do not reply.

+
+
+ + diff --git a/src/email/templates/fire-service-inspection-receipt.hbs b/src/email/templates/fire-service-inspection-receipt.hbs new file mode 100644 index 0000000..5ecfac4 --- /dev/null +++ b/src/email/templates/fire-service-inspection-receipt.hbs @@ -0,0 +1,54 @@ + + + + + + +
+
+ Government of Barbados +
+
+ +
+

Fire Service Inspection Request - Submission Received

+ +
+

Your Submission ID: {{submissionId}}

+

Please save this ID for your records.

+
+ +
+

What happens next

+

Your fire service inspection request has been received. A fire safety officer will contact you to schedule the inspection at your property. Please ensure the premises are accessible on the scheduled date.

+
+ +
+

Need help?

+

If you have questions about your inspection request, please contact the Barbados Fire Service.

+
+
+ +
+
+

Government of Barbados
+ This is an automated confirmation email. Please do not reply.

+
+
+ + diff --git a/src/email/templates/jobstart-plus-programme-receipt.hbs b/src/email/templates/jobstart-plus-programme-receipt.hbs new file mode 100644 index 0000000..912b088 --- /dev/null +++ b/src/email/templates/jobstart-plus-programme-receipt.hbs @@ -0,0 +1,54 @@ + + + + + + +
+
+ Government of Barbados +
+
+ +
+

JobStart Plus Programme Application - Submission Received

+ +
+

Your Submission ID: {{submissionId}}

+

Please save this ID for your records.

+
+ +
+

What happens next

+

Your application for the JobStart Plus Programme has been received. Our team will review your submission and if shortlisted, you will be contacted for an interview. Please ensure your contact information is accurate.

+
+ +
+

Need help?

+

If you have questions about your application, please contact the Ministry of Labour.

+
+
+ +
+
+

Government of Barbados
+ This is an automated confirmation email. Please do not reply.

+
+
+ + diff --git a/src/email/templates/jobstart-plus-programme.hbs b/src/email/templates/jobstart-plus-programme.hbs index 53aeca5..ea21735 100644 --- a/src/email/templates/jobstart-plus-programme.hbs +++ b/src/email/templates/jobstart-plus-programme.hbs @@ -18,7 +18,11 @@ border-radius: 5px; } .old-address { background-color: #fed7d7; } .new-address { background-color: #c6f6d5; } .footer { margin-top: 30px; padding: 20px; background-color: #edf2f7; border-radius: 5px; text-align: - center; font-size: 14px; color: #718096; } + center; font-size: 14px; color: #718096; } .education-item { + background-color: #f7fafc; padding: 15px; margin-bottom: 10px; + border-radius: 5px; border-left: 3px solid #2c5282; } .employment-item { + background-color: #f7fafc; padding: 15px; margin-bottom: 10px; + border-radius: 5px; border-left: 3px solid #48bb78; } @@ -35,6 +39,7 @@ Name: {{applicant.title}} {{applicant.firstName}} + {{#if applicant.middleName}}{{applicant.middleName}}{{/if}} {{applicant.lastName}}
@@ -42,66 +47,274 @@ {{applicant.dateOfBirth}}
- ID Number: - {{applicant.idNumber}} + Sex: + {{applicant.sex}}
+
+ Marital Status: + {{applicant.maritalStatus}} +
+ {{#if applicant.idNumber}} +
+ ID Number: + {{applicant.idNumber}} +
+ {{/if}} {{#if applicant.passportNumber}}
Passport Number: {{applicant.passportNumber}}
{{/if}} - {{#if applicant.email}} + {{#if applicant.hasNisNumber}}
- Email: - {{applicant.email}} + Has NIS Number: + {{applicant.hasNisNumber}} +
+ {{/if}} + {{#if applicant.nisNumber}} +
+ NIS Number: + {{applicant.nisNumber}} +
+ {{/if}} +
+ Has Disability: + {{applicant.hasDisability}} +
+ {{#if applicant.disabilityDetails}} +
+ Disability Details: + {{applicant.disabilityDetails}}
{{/if}} -
Contact Details
-
-
+
+ Address Line 1: + {{contactDetails.addressLine1}} +
+ {{#if contactDetails.addressLine2}} +
+ Address Line 2: + {{contactDetails.addressLine2}} +
+ {{/if}} +
+ Parish: + {{contactDetails.parish}} +
+ {{#if contactDetails.postalCode}} +
+ Postal Code: + {{contactDetails.postalCode}} +
+ {{/if}} +
+ Email: + {{contactDetails.email}} +
+
+ Phone Number: + {{contactDetails.telephoneNumber}} +
+
+ +
+
Emergency Contact
+
+ Name: + {{emergency.title}} + {{emergency.firstName}} + {{emergency.lastName}} +
+
+ Relationship: + {{emergency.relationship}} +
+
+ Address Line 1: + {{emergency.addressLine1}} +
+ {{#if emergency.addressLine2}} +
+ Address Line 2: + {{emergency.addressLine2}} +
+ {{/if}} +
+ Parish: + {{emergency.parish}} +
+ {{#if emergency.postalCode}} +
+ Postal Code: + {{emergency.postalCode}} +
+ {{/if}} +
+ Email: + {{emergency.email}} +
+
+ Phone Number: + {{emergency.telephoneNumber}} +
+
+ +
+
Primary Education
+
+ School Name: + {{primaryEducation.schoolName}} +
+
+ Start Year: + {{primaryEducation.startYear}} +
+
+ End Year: + {{primaryEducation.endYear}} +
+
+ +
+
Secondary Education
+
+ School Name: + {{secondaryEducation.schoolName}} +
+
+ Start Year: + {{secondaryEducation.startYear}} +
+
+ End Year: + {{secondaryEducation.endYear}} +
+
+ + {{#if postSecondaryEducation}} +
+
Post-Secondary Education
+ {{#each postSecondaryEducation}} +
+ {{#if this.institutionName}} +
+ Institution: + {{this.institutionName}} +
+ {{/if}} + {{#if this.qualificationsObtained}} +
+ Qualifications: + {{this.qualificationsObtained}} +
+ {{/if}} + {{#if this.coursesOrSubjects}} +
+ Courses/Subjects: + {{this.coursesOrSubjects}} +
+ {{/if}} + {{#if this.startDate}} +
+ Start Date: + {{this.startDate}} +
+ {{/if}} + {{#if this.endDate}} +
+ End Date: + {{this.endDate}} +
+ {{/if}} +
+ {{/each}} +
+ {{/if}} + +
+
Employment History
+
+ Has Previous Paid Job: + {{hasPreviousPaidJob}} +
+ {{#if employmentHistory}} + {{#each employmentHistory}} +
+ {{#if this.employerName}} +
+ Employer: + {{this.employerName}} +
+ {{/if}} + {{#if this.occupation}} +
+ Occupation: + {{this.occupation}} +
+ {{/if}} + {{#if this.startDate}} +
+ Start Date: + {{this.startDate}} +
+ {{/if}} + {{#if this.endDate}} +
+ End Date: + {{this.endDate}} +
+ {{/if}} + {{#if this.currentlyWorkingHere}} +
+ Currently Working Here: + {{this.currentlyWorkingHere}} +
+ {{/if}} + {{#if this.mainTasks}} +
+ Main Tasks: + {{this.mainTasks}} +
+ {{/if}} +
+ {{/each}} + {{/if}} +
+ + {{#if eligibility}} +
+
Eligibility Information
+ {{#if eligibility.interests}}
- Address Line 1: - {{contactDetails.addressLine1}} + Job/Trade Interests: + {{eligibility.interests}}
- {{#if contactDetails.addressLine2}} -
- Address Line 2: - {{contactDetails.addressLine2}} -
- {{/if}} + {{/if}} + {{#if eligibility.areYouOver18}}
- Parish: - {{contactDetails.parish}} + Over 18: + {{eligibility.areYouOver18}}
- {{#if contactDetails.postalCode}} -
- Postal Code: - {{contactDetails.postalCode}} -
- {{/if}} - - {{#if contactDetails.email}} -
- Email: - {{contactDetails.email}} -
- {{/if}} - {{#if contactDetails.telephoneNumber}} -
- Phone Number: - {{contactDetails.telephoneNumber}} -
- {{/if}} -
+ {{/if}} + {{#if eligibility.willingToWorkAtNight}} +
+ Willing to Work at Night: + {{eligibility.willingToWorkAtNight}} +
+ {{/if}} + {{#if eligibility.shortTermGoals}} +
+ Short-term Goals: + {{eligibility.shortTermGoals}} +
+ {{/if}}
-
+ {{/if}}
- \ No newline at end of file + diff --git a/src/email/templates/marriage-certificate-receipt.hbs b/src/email/templates/marriage-certificate-receipt.hbs new file mode 100644 index 0000000..345cb75 --- /dev/null +++ b/src/email/templates/marriage-certificate-receipt.hbs @@ -0,0 +1,54 @@ + + + + + + +
+
+ Government of Barbados +
+
+ +
+

Marriage Certificate Application - Submission Received

+ +
+

Your Submission ID: {{submissionId}}

+

Please save this ID for your records.

+
+ +
+

What happens next

+

Your application for a marriage certificate has been received. We will process your request and contact you with payment instructions and collection details for your certified copy.

+
+ +
+

Need help?

+

If you have questions about your application, please contact the Registration Department at the Supreme Court Complex.

+
+
+ +
+
+

Government of Barbados
+ This is an automated confirmation email. Please do not reply.

+
+
+ + diff --git a/src/email/templates/permission-to-remove-tree-receipt.hbs b/src/email/templates/permission-to-remove-tree-receipt.hbs new file mode 100644 index 0000000..9350903 --- /dev/null +++ b/src/email/templates/permission-to-remove-tree-receipt.hbs @@ -0,0 +1,54 @@ + + + + + + +
+
+ Government of Barbados +
+
+ +
+

Permission to Remove Tree - Submission Received

+ +
+

Your Submission ID: {{submissionId}}

+

Please save this ID for your records.

+
+ +
+

What happens next

+

Your application for permission to remove a tree has been received. An environmental officer will review your request and may conduct a site visit. You will be notified of the decision once the review is complete.

+
+ +
+

Need help?

+

If you have questions about your application, please contact the Environmental Protection Department.

+
+
+ +
+
+

Government of Barbados
+ This is an automated confirmation email. Please do not reply.

+
+
+ + diff --git a/src/email/templates/post-office-redirection-business-receipt.hbs b/src/email/templates/post-office-redirection-business-receipt.hbs new file mode 100644 index 0000000..3da672a --- /dev/null +++ b/src/email/templates/post-office-redirection-business-receipt.hbs @@ -0,0 +1,119 @@ + + + + + + +
+
+ +
+
+ +
+

Thank you for your application

+ +

+ We have successfully received your business mail redirection application + for {{businessName}}. +

+ +

Submission ID: {{submissionId}}

+ +
+

Redirection Details

+

+ Start Date: + {{newAddress.redirectionStartDate}}
+ End Date: + {{newAddress.redirectionEndDate}} +

+
+ +
+

What happens next?

+

+ Your mail redirection will be activated on the start date specified. + We will contact you if we require any additional information. +

+
+
+ +
+
+

Government of Barbados - Civil Registration Department
+ This is an automated confirmation email. Please do not reply to this + email.

+
+
+ + \ No newline at end of file diff --git a/src/email/templates/post-office-redirection-deceased-receipt.hbs b/src/email/templates/post-office-redirection-deceased-receipt.hbs new file mode 100644 index 0000000..2c5c0d7 --- /dev/null +++ b/src/email/templates/post-office-redirection-deceased-receipt.hbs @@ -0,0 +1,111 @@ + + + + + + +
+
+ +
+
+ +
+

Thank you for your application

+ +

+ We have successfully received your mail redirection request for + {{deceased.firstName}} + {{deceased.lastName}}. +

+ +

Submission ID: {{submissionId}}

+ +
+

What happens next?

+

+ Your mail redirection request for the deceased's mail is being + processed. We will contact you if we require any additional + information. +

+
+
+ +
+
+

Government of Barbados - Civil Registration Department
+ This is an automated confirmation email. Please do not reply to this + email.

+
+
+ + \ No newline at end of file diff --git a/src/email/templates/post-office-redirection-individual-receipt.hbs b/src/email/templates/post-office-redirection-individual-receipt.hbs new file mode 100644 index 0000000..6a4f008 --- /dev/null +++ b/src/email/templates/post-office-redirection-individual-receipt.hbs @@ -0,0 +1,54 @@ + + + + + + +
+
+ Government of Barbados +
+
+ +
+

Individual Mail Redirection - Submission Received

+ +
+

Your Submission ID: {{submissionId}}

+

Please save this ID for your records.

+
+ +
+

What happens next

+

Your request to redirect your personal mail has been received. The Barbados Postal Service will process your request and begin redirecting mail to your new address within the specified timeframe. You will receive confirmation once the redirection is active.

+
+ +
+

Need help?

+

If you have questions about your mail redirection, please contact the Barbados Postal Service.

+
+
+ +
+
+

Government of Barbados
+ This is an automated confirmation email. Please do not reply.

+
+
+ + diff --git a/src/email/templates/post-office-redirection-notice.hbs b/src/email/templates/post-office-redirection-notice.hbs index 5ba9bd9..da54882 100644 --- a/src/email/templates/post-office-redirection-notice.hbs +++ b/src/email/templates/post-office-redirection-notice.hbs @@ -21,152 +21,295 @@ center; font-size: 14px; color: #718096; } .document-list { list-style: none; padding-left: 0; } .document-list li { padding: 8px; background-color: #edf2f7; margin-bottom: 8px; border-radius: 3px; - word-break: break-all; } + word-break: break-all; } .minor-item { background-color: #f7fafc; + padding: 10px; margin-bottom: 8px; border-radius: 5px; + border-left: 3px solid #2c5282; }
-

📮 Post Office Redirection for Deceased - Request Submission

+ {{#if deceased}} +

Post Office Redirection for Deceased - Request Submission

+ {{else if businessName}} +

Post Office Redirection for Business - Request Submission

+ {{else}} +

Post Office Redirection for Individual - Request Submission

+ {{/if}}
-

A new post office redirection request for a deceased person has been - submitted.

+ {{#if deceased}} +

A new post office redirection request for a deceased person has been + submitted.

+ {{else if businessName}} +

A new post office redirection request for a business has been + submitted.

+ {{else}} +

A new post office redirection request for an individual has been + submitted.

+ {{/if}} - -
-
Deceased Person Information
-
- Title: - {{deceased.title}} -
-
- First Name: - {{deceased.firstName}} -
- {{#if deceased.middleName}} + + {{#if deceased}} +
+
Deceased Person Information
- Middle Name: - {{deceased.middleName}} + Title: + {{deceased.title}}
- {{/if}} -
- Last Name: - {{deceased.lastName}} +
+ First Name: + {{deceased.firstName}} +
+ {{#if deceased.middleName}} +
+ Middle Name: + {{deceased.middleName}} +
+ {{/if}} +
+ Last Name: + {{deceased.lastName}} +
+ {{#if deceased.dateOfDeath}} +
+ Date of Death: + {{deceased.dateOfDeath}} +
+ {{/if}}
- {{#if deceased.dateOfDeath}} + {{/if}} + + + {{#if businessName}} +
+
Business Information
- Date of Death: - {{deceased.dateOfDeath}} + Business Name: + {{businessName}}
- {{/if}} -
+
+ {{/if}} - +
-
Old/Previous Address
-
- Address Line 1: - {{oldAddress.addressLine1}} -
- {{#if oldAddress.addressLine2}} +
{{#if businessName}}Current Address{{else}}Old/Previous + Address{{/if}}
+ {{#if oldAddress}}
- Address Line 2: - {{oldAddress.addressLine2}} + Address Line 1: + {{oldAddress.addressLine1}}
+ {{#if oldAddress.addressLine2}} +
+ Address Line 2: + {{oldAddress.addressLine2}} +
+ {{/if}} +
+ Parish: + {{oldAddress.parish}} +
+ {{#if oldAddress.postcode}} +
+ Postcode: + {{oldAddress.postcode}} +
+ {{/if}} + {{#if oldAddress.postalCode}} +
+ Postal Code: + {{oldAddress.postalCode}} +
+ {{/if}} {{/if}} -
- Parish: - {{oldAddress.parish}} -
- {{#if oldAddress.postcode}} + {{#if currentAddress}}
- Postcode: - {{oldAddress.postcode}} + Address Line 1: + {{currentAddress.addressLine1}}
+ {{#if currentAddress.addressLine2}} +
+ Address Line 2: + {{currentAddress.addressLine2}} +
+ {{/if}} +
+ Parish: + {{currentAddress.parish}} +
+ {{#if currentAddress.postcode}} +
+ Postcode: + {{currentAddress.postcode}} +
+ {{/if}} {{/if}}
-
-
Applicant Information
-
- Title: - {{applicant.title}} -
-
- First Name: - {{applicant.firstName}} -
-
- Last Name: - {{applicant.lastName}} -
-
- Relationship to Deceased: - {{applicant.relationshipToDeceased}} -
-
- Email Address: - {{applicant.email}} -
-
- Telephone Number: - {{applicant.telephoneNumber}} + {{#if applicant}} +
+
{{#if deceased}}Applicant + Information{{else if businessName}}Contact Person + Information{{else}}Applicant Information{{/if}}
+ {{#if applicant.title}} +
+ Title: + {{applicant.title}} +
+ {{/if}} +
+ First Name: + {{applicant.firstName}} +
+ {{#if applicant.middleName}} +
+ Middle Name: + {{applicant.middleName}} +
+ {{/if}} +
+ Last Name: + {{applicant.lastName}} +
+ {{#if applicant.dateOfBirth}} +
+ Date of Birth: + {{applicant.dateOfBirth}} +
+ {{/if}} + {{#if applicant.idNumber}} +
+ ID Number: + {{applicant.idNumber}} +
+ {{/if}} + {{#if applicant.passportNumber}} +
+ Passport Number: + {{applicant.passportNumber}} +
+ {{/if}} + {{#if applicant.relationshipToDeceased}} +
+ Relationship to Deceased: + {{applicant.relationshipToDeceased}} +
+ {{/if}} +
+ Email Address: + {{applicant.email}} +
+
+ Telephone Number: + {{applicant.telephoneNumber}} +
-
+ {{/if}} - -
-
Permission Details
-
- Permission to Act on Estate: - {{permissionDetails}} + + {{#if permissionDetails}} +
+
Permission Details
+
+ {{#if deceased}}Permission to Act on + Estate:{{else}}Permission to Act for Business:{{/if}} + {{permissionDetails}} +
-
+ {{/if}} -
-
New Address for Mail Redirection
-
- Address Line 1: - {{newAddress.addressLine1}} -
- {{#if newAddress.addressLine2}} + {{#if newAddress}} +
+
New Address for Mail Redirection
- Address Line 2: - {{newAddress.addressLine2}} + Address Line 1: + {{newAddress.addressLine1}}
- {{/if}} -
- Parish: - {{newAddress.parish}} -
- {{#if newAddress.postcode}} + {{#if newAddress.addressLine2}} +
+ Address Line 2: + {{newAddress.addressLine2}} +
+ {{/if}}
- Postcode: - {{newAddress.postcode}} + Parish: + {{newAddress.parish}}
- {{/if}} -
- Permanent Redirection: - {{newAddress.isRedirectPermanent}} + {{#if newAddress.postcode}} +
+ Postcode: + {{newAddress.postcode}} +
+ {{/if}} + {{#if newAddress.postalCode}} +
+ Postal Code: + {{newAddress.postalCode}} +
+ {{/if}} + {{#if newAddress.isRedirectPermanent}} +
+ Permanent Redirection: + {{newAddress.isRedirectPermanent}} +
+ {{/if}} + {{#if newAddress.isMovingPermanent}} +
+ Moving Permanently: + {{newAddress.isMovingPermanent}} +
+ {{/if}} + {{#if newAddress.redirectionStartDate}} +
+ Redirection Start Date: + {{newAddress.redirectionStartDate}} +
+ {{/if}} + {{#if newAddress.redirectionEndDate}} +
+ Redirection End Date: + {{newAddress.redirectionEndDate}} +
+ {{/if}}
- {{#if newAddress.redirectionStartDate}} -
- Redirection Start Date: - {{newAddress.redirectionStartDate}} -
- {{/if}} - {{#if newAddress.redirectionEndDate}} + {{/if}} + + + {{#if anyMinorDependents}} +
+
Minor Dependents Information
- Redirection End Date: - {{newAddress.redirectionEndDate}} + Any Minor Dependents: + {{anyMinorDependents}}
- {{/if}} -
+ {{#if minorDetails}} +
+ Minor Dependents: + {{#each minorDetails}} +
+ {{#if this.firstName}} +
+ First Name: + {{this.firstName}} +
+ {{/if}} + {{#if this.lastName}} +
+ Last Name: + {{this.lastName}} +
+ {{/if}} +
+ {{/each}} +
+ {{/if}} +
+ {{/if}} - + {{#if uploadDocumentUrls}}
Uploaded Documents
@@ -186,9 +329,12 @@

Submission ID: {{submissionId}}

This is an automated notification from the Post Office Redirection System.

-

Please review and process this mail redirection request for the - deceased person's estate.

+

{{#if deceased}}Please review and process this mail redirection + request for the deceased person's estate.{{else if businessName}}Please + review and process this business mail redirection + request.{{else}}Please review and process this mail redirection + request.{{/if}}

- \ No newline at end of file + diff --git a/src/email/templates/primary-school-textbook-grant-receipt.hbs b/src/email/templates/primary-school-textbook-grant-receipt.hbs index 37959a9..997e63e 100644 --- a/src/email/templates/primary-school-textbook-grant-receipt.hbs +++ b/src/email/templates/primary-school-textbook-grant-receipt.hbs @@ -2,160 +2,53 @@
- - - - - - - - - - - - - - - - - - - - - - - - - - + Government of Barbados
-

Thank you for your application

+

Primary School Textbook Grant - Submission Received

-
-

- Your textbook grant application has been submitted to the Ministry of - Education. -

+
+

Your Submission ID: {{submissionId}}

+

Please save this ID for your records.

-
-

Application Details

-

- Student: - {{beneficiaries.firstName}} - {{beneficiaries.lastName}} -

-

Class: Class {{beneficiaries.class}}

-

- Applicant: - {{applicant.title}} - {{applicant.firstName}} - {{applicant.lastName}} -

-

Contact: {{contact.telephoneNumber}}

-

Email: {{applicant.email}}

- {{#if guardian.email}} -

Guardian Email: {{guardian.email}}

- {{/if}} +
+

What happens next

+

Your application for the Primary School Textbook Grant has been received. The Ministry of Education will review your application and process it according to grant eligibility criteria. You will be notified of the outcome.

-

What happens next?

-

- Your application will be reviewed and processed according to grant - eligibility criteria. -

+

Need help?

+

If you have questions about your application, please contact the Ministry of Education, Technological and Vocational Training.

-
-

- Government of Barbados - Ministry of Education, Technological and - Vocational Training
- This is an automated confirmation from the Primary School Textbook - Grant Program. -

+

Government of Barbados
+ This is an automated confirmation email. Please do not reply.

- \ No newline at end of file + diff --git a/src/email/templates/primary-school-textbook-grant.hbs b/src/email/templates/primary-school-textbook-grant.hbs index bc39dd3..c6f6b7e 100644 --- a/src/email/templates/primary-school-textbook-grant.hbs +++ b/src/email/templates/primary-school-textbook-grant.hbs @@ -17,52 +17,95 @@ background-color: #edf2f7; border-radius: 5px; text-align: center; font-size: 14px; color: #718096; } .financial-highlight { background-color: #fef3c7; padding: 10px; border-radius: 5px; margin-top: - 10px; border-left: 4px solid #f59e0b; } + 10px; border-left: 4px solid #f59e0b; } .beneficiary-item { + background-color: #f0f9ff; padding: 15px; margin-bottom: 15px; + border-radius: 5px; border-left: 4px solid #0284c7; }
-

📚 Primary School Textbook Grant Application

+

Primary School Textbook Grant Application

A new textbook grant application has been submitted.

-
-
Student Information
-
- Student Name: - {{beneficiaries.firstName}} - {{beneficiaries.lastName}} -
- {{#if beneficiaries.idNumber}} -
- Student ID: - {{beneficiaries.idNumber}} -
- {{/if}} - {{#if beneficiaries.passportNumber}} -
- Passport Number: - {{beneficiaries.passportNumber}} -
- {{/if}} -
- Class/Grade: - Class {{beneficiaries.class}} -
-
- Relationship to Child: - {{beneficiaries.relationshipToChild}} -
-
+ {{#if beneficiaries}} +
+
Student Information (Beneficiaries)
+ {{#each beneficiaries}} +
+ {{#if this.value}} +
+ Student: + {{this.value}} +
+ {{/if}} + {{#if this.guardian}} +
+ Guardian Information: +
+ {{#if this.guardian.title}} +
+ Title: + {{this.guardian.title}} +
+ {{/if}} + {{#if this.guardian.firstName}} +
+ First Name: + {{this.guardian.firstName}} +
+ {{/if}} + {{#if this.guardian.middleName}} +
+ Middle Name: + {{this.guardian.middleName}} +
+ {{/if}} + {{#if this.guardian.lastName}} +
+ Last Name: + {{this.guardian.lastName}} +
+ {{/if}} + {{#if this.guardian.idNumber}} +
+ ID Number: + {{this.guardian.idNumber}} +
+ {{/if}} + {{#if this.guardian.gender}} +
+ Gender: + {{this.guardian.gender}} +
+ {{/if}} + {{#if this.guardian.relationship}} +
+ Relationship: + {{this.guardian.relationship}} +
+ {{/if}} + {{#if this.guardian.email}} +
+ Email: + {{this.guardian.email}} +
+ {{/if}} +
+
+ {{/if}} +
+ {{/each}} +
+ {{/if}}
Applicant Information
Name: - {{applicant.title}} - {{applicant.firstName}} + {{applicant.firstName}} {{applicant.lastName}}
@@ -109,91 +152,47 @@ {{/if}}
-
-
Parent/Guardian Relationship
-
- Are you the parent or guardian? - {{guardianOrParentRelationship}} -
-
- -
-
Guardian Information
- {{#if guardian.title}} -
- Title: - {{guardian.title}} -
- {{/if}} -
- Guardian Name: - {{guardian.firstName}} - {{#if guardian.middleName}}{{guardian.middleName}} - {{/if}}{{guardian.lastName}} -
-
- Guardian ID: - {{guardian.idNumber}} -
- {{#if guardian.gender}} -
- Gender: - {{guardian.gender}} -
- {{/if}} -
- Relationship to Student: - {{guardian.relationship}} -
- {{#if guardian.email}} -
- Email: - {{guardian.email}} + {{#if bankAccount}} +
+
Bank Account Information
+
+
+ Account Holder Name: + {{bankAccount.accountHolderName}} +
+
+ Bank Name: + {{bankAccount.bankName}} +
+
+ Account Number: + {{bankAccount.accountNumber}} +
+ {{#if bankAccount.branchName}} +
+ Branch Name: + {{bankAccount.branchName}} +
+ {{/if}} + {{#if bankAccount.branchCode}} +
+ Branch Code: + {{bankAccount.branchCode}} +
+ {{/if}} + {{#if bankAccount.branchLocation}} +
+ Branch Location: + {{bankAccount.branchLocation}} +
+ {{/if}} +
+ Account Type: + {{bankAccount.accountType}} +
- {{/if}} -
- -
-
Contact Information
-
- Address: - {{contact.addressLine1}}{{#if - contact.addressLine2 - }}, {{contact.addressLine2}}{{/if}} -
-
- Parish: - {{contact.parish}} -
-
- Phone: - {{contact.telephoneNumber}} -
-
- -
-
Bank Account Information
-
- Account Holder Name: - {{bankAccount.accountHolderName}} -
-
- Bank Name: - {{bankAccount.bankName}}
-
- Account Number: - {{bankAccount.accountNumber}} -
-
- Branch Location: - {{bankAccount.branchLocation}} -
-
- Account Type: - {{bankAccount.accountType}} -
-
+ {{/if}}
- \ No newline at end of file + diff --git a/src/email/templates/request-a-fire-service-inspection.hbs b/src/email/templates/request-a-fire-service-inspection.hbs new file mode 100644 index 0000000..feed249 --- /dev/null +++ b/src/email/templates/request-a-fire-service-inspection.hbs @@ -0,0 +1,100 @@ + + + + + + +
+

🔥 Fire Service Inspection Request

+
+ +
+

A new fire service inspection request has been submitted.

+ +
+
Premises Information
+
+ Type of Premises: + + {{#if (eq premises.typeOfPremises 'hotel')}} + Hotel + {{else if (eq premises.typeOfPremises 'daycare')}} + Daycare + {{else if (eq premises.typeOfPremises 'placeOfEntertainment')}} + Place of Entertainment + {{else}} + {{premises.typeOfPremises}} + {{/if}} + +
+
+ Name of Premises: + {{premises.nameOfPremises}} +
+
+ Address: + {{premises.addressLine1}}{{#if premises.addressLine2}}, {{premises.addressLine2}}{{/if}} +
+
+ Parish: + {{premises.parish}} +
+
+ +
+
Certificate Purpose
+
+ Certificate For: + + {{#if (eq purposeOfCertificate 'barbados-tourism-authority')}} + Barbados Tourism Authority + {{else if (eq purposeOfCertificate 'child-care-board')}} + Child Care Board + {{else if (eq purposeOfCertificate 'treasury')}} + Treasury + {{else}} + {{purposeOfCertificate}} + {{/if}} + +
+
+ +
+
Applicant Information
+
+ Name: + {{applicant.firstName}} {{applicant.lastName}} +
+
+ Email: + {{applicant.email}} +
+
+ Telephone: + {{applicant.telephoneNumber}} +
+
+ + +
+ + diff --git a/src/email/templates/reserve-company-name-receipt.hbs b/src/email/templates/reserve-company-name-receipt.hbs new file mode 100644 index 0000000..76db7cd --- /dev/null +++ b/src/email/templates/reserve-company-name-receipt.hbs @@ -0,0 +1,54 @@ + + + + + + +
+
+ Government of Barbados +
+
+ +
+

Reserve Company Name - Submission Received

+ +
+

Your Submission ID: {{submissionId}}

+

Please save this ID for your records.

+
+ +
+

What happens next

+

Your request to reserve a company name has been received. The Corporate Affairs and Intellectual Property Office will review your submission to ensure the name is available and meets all requirements. You will be notified of the outcome.

+
+ +
+

Need help?

+

If you have questions about your application, please contact the Corporate Affairs and Intellectual Property Office.

+
+
+ +
+
+

Government of Barbados
+ This is an automated confirmation email. Please do not reply.

+
+
+ + diff --git a/src/email/templates/reserve-company-name.hbs b/src/email/templates/reserve-company-name.hbs index a798735..bb92723 100644 --- a/src/email/templates/reserve-company-name.hbs +++ b/src/email/templates/reserve-company-name.hbs @@ -17,7 +17,9 @@ background-color: #edf2f7; border-radius: 5px; text-align: center; font-size: 14px; color: #718096; } .financial-highlight { background-color: #fef3c7; padding: 10px; border-radius: 5px; margin-top: - 10px; border-left: 4px solid #f59e0b; } + 10px; border-left: 4px solid #f59e0b; } .name-choice { + background-color: #e0f2fe; padding: 10px; margin: 10px 0; + border-radius: 5px; border-left: 4px solid #0284c7; } @@ -36,12 +38,40 @@
{{#if companyPresentName}}
- What is the present name of the company? + Present Company Name: {{companyPresentName}}
{{/if}}
+
+
Company Name Choices
+
+
+ First Choice: + {{companyName.firstChoice}} +
+
+
+
+ Second Choice: + {{companyName.secondChoice}} +
+
+
+
+ Third Choice: + {{companyName.thirdChoice}} +
+
+ {{#if companyName.reserveFirstAvailableName}} +
+ Reserve First Available Name: + {{companyName.reserveFirstAvailableName}} +
+ {{/if}} +
+
Business Activities
{{#each businessActivity}} @@ -55,8 +85,7 @@
Applicant Information
Name: - {{applicant.title}} - {{applicant.firstName}} + {{applicant.firstName}} {{applicant.lastName}}
@@ -83,36 +112,6 @@ Phone: {{applicant.telephoneNumber}}
- -
- -
-
Request Details
-
- Purpose: - {{request.purpose}} -
- {{#if request.currentSocietyName}} -
- Current Society Name: - {{request.currentSocietyName}} -
- {{/if}} -
- -
-
Contact Information
-
- Address: - {{applicant.addressLine1}}{{#if - applicant.addressLine2 - }}, {{applicant.addressLine2}}{{/if}} -
-
- Parish: - {{applicant.parish}} -
-
- \ No newline at end of file + diff --git a/src/email/templates/reserve-society-name-receipt.hbs b/src/email/templates/reserve-society-name-receipt.hbs new file mode 100644 index 0000000..dd5f0f9 --- /dev/null +++ b/src/email/templates/reserve-society-name-receipt.hbs @@ -0,0 +1,54 @@ + + + + + + +
+
+ Government of Barbados +
+
+ +
+

Reserve Society Name - Submission Received

+ +
+

Your Submission ID: {{submissionId}}

+

Please save this ID for your records.

+
+ +
+

What happens next

+

Your request to reserve a society name has been received. The Corporate Affairs and Intellectual Property Office will review your submission to ensure the name is available and meets all requirements. You will be notified of the outcome.

+
+ +
+

Need help?

+

If you have questions about your application, please contact the Corporate Affairs and Intellectual Property Office.

+
+
+ +
+
+

Government of Barbados
+ This is an automated confirmation email. Please do not reply.

+
+
+ + diff --git a/src/email/templates/sell-goods-services-beach-park-notice.hbs b/src/email/templates/sell-goods-services-beach-park-notice.hbs index 87f623e..73759a3 100644 --- a/src/email/templates/sell-goods-services-beach-park-notice.hbs +++ b/src/email/templates/sell-goods-services-beach-park-notice.hbs @@ -36,6 +36,7 @@ Name: {{applicant.title}} {{applicant.firstName}} + {{#if applicant.middleName}}{{applicant.middleName}}{{/if}} {{applicant.lastName}}
@@ -43,31 +44,159 @@ {{applicant.dateOfBirth}}
- ID Number: - {{applicant.idNumber}} + Nationality: + {{applicant.nationality}}
+ {{#if applicant.idNumber}} +
+ ID Number: + {{applicant.idNumber}} +
+ {{/if}} {{#if applicant.passportNumber}}
Passport Number: {{applicant.passportNumber}}
{{/if}} - {{#if applicant.email}} +
+ Email: + {{applicant.email}} +
+
+ Phone Number: + {{applicant.telephoneNumber}} +
+
+ +
+
Applicant Address
+
+ Address Line 1: + {{applicant.addressLine1}} +
+ {{#if applicant.addressLine2}} +
+ Address Line 2: + {{applicant.addressLine2}} +
+ {{/if}} +
+ Parish: + {{applicant.parish}} +
+ {{#if applicant.postalCode}} +
+ Postal Code: + {{applicant.postalCode}} +
+ {{/if}} +
+ +
+
Selling Details
+
+ Selling: + {{selling.goodsOrServices}} +
+ {{#if selling.manufacturingLocation}} +
+ Manufacturing Location: + {{selling.manufacturingLocation}} +
+ {{/if}} +
+ +
+
Business Information
+
+ Description of Goods/Services: + {{business.descriptionOfGoodsOrServices}} +
+
+ Intended Place of Business: + {{business.intendedPlaceOfDoingBusiness}} +
+
+ +
+
Professional Referee
+
+ Name: + {{professionalReferee.firstName}} + {{professionalReferee.lastName}} +
+
+ Relationship: + {{professionalReferee.relationship}} +
+
+ Email: + {{professionalReferee.email}} +
+
+ Phone Number: + {{professionalReferee.telephoneNumber}} +
+
+ Address Line 1: + {{professionalReferee.addressLine1}} +
+ {{#if professionalReferee.addressLine2}} +
+ Address Line 2: + {{professionalReferee.addressLine2}} +
+ {{/if}} +
+ Parish: + {{professionalReferee.parish}} +
+ {{#if professionalReferee.postcode}}
- Email: - {{applicant.email}} + Postcode: + {{professionalReferee.postcode}}
{{/if}} - {{#if applicant.email}} +
+ +
+
Personal Referee
+
+ Name: + {{personalReferee.firstName}} + {{personalReferee.lastName}} +
+
+ Relationship: + {{personalReferee.relationship}} +
+
+ Email: + {{personalReferee.email}} +
+
+ Phone Number: + {{personalReferee.telephoneNumber}} +
+
+ Address Line 1: + {{personalReferee.addressLine1}} +
+ {{#if personalReferee.addressLine2}}
- Email: - {{applicant.email}} + Address Line 2: + {{personalReferee.addressLine2}}
{{/if}} - {{#if applicant.telephoneNumber}} +
+ Parish: + {{personalReferee.parish}} +
+ {{#if personalReferee.postcode}}
- Phone Number: - {{applicant.telephoneNumber}} + Postcode: + {{personalReferee.postcode}}
{{/if}}
@@ -75,8 +204,8 @@
- \ No newline at end of file + diff --git a/src/email/templates/sell-goods-services-beach-park-receipt.hbs b/src/email/templates/sell-goods-services-beach-park-receipt.hbs new file mode 100644 index 0000000..bdcdb7f --- /dev/null +++ b/src/email/templates/sell-goods-services-beach-park-receipt.hbs @@ -0,0 +1,54 @@ + + + + + + +
+
+ Government of Barbados +
+
+ +
+

Sell Goods or Services at Beach/Park Notice - Submission Received

+ +
+

Your Submission ID: {{submissionId}}

+

Please save this ID for your records.

+
+ +
+

What happens next

+

Your notice of intent to sell goods or services at a beach or park has been received. The relevant authority will review your submission and contact you regarding permit requirements and any fees that may apply.

+
+ +
+

Need help?

+

If you have questions about your submission, please contact the National Conservation Commission.

+
+
+ +
+
+

Government of Barbados
+ This is an automated confirmation email. Please do not reply.

+
+
+ + From 2bd9d9fbd48ff895706e16e607acedc4ceb969ae Mon Sep 17 00:00:00 2001 From: Akinola Raphael Date: Wed, 21 Jan 2026 15:21:35 +0100 Subject: [PATCH 13/49] chore: update get birth cert --- schemas/get-birth-certificate.json | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/schemas/get-birth-certificate.json b/schemas/get-birth-certificate.json index 0e94081..d15c9ed 100644 --- a/schemas/get-birth-certificate.json +++ b/schemas/get-birth-certificate.json @@ -112,13 +112,13 @@ "name": "email", "type": "email", "label": "Email address", - "required": true + "required": false }, { "name": "telephoneNumber", "type": "string", "label": "Telephone number", - "required": true + "required": false } ] }, @@ -244,11 +244,7 @@ "name": "postalCode", "type": "string", "label": "Postal Code", - "required": false, - "validations": { - "regex": "^BB\\d{5}$", - "message": "Enter a valid postal code (e.g., BB17004)" - } + "required": false }, { "name": "idNumber", From 25ca6d28a23a330d5ad21fb0bc78f2c2d3d159f0 Mon Sep 17 00:00:00 2001 From: Akinola Raphael Date: Wed, 21 Jan 2026 15:27:09 +0100 Subject: [PATCH 14/49] chore: update get birth cert postal validation --- schemas/get-birth-certificate.json | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/schemas/get-birth-certificate.json b/schemas/get-birth-certificate.json index d15c9ed..36e3217 100644 --- a/schemas/get-birth-certificate.json +++ b/schemas/get-birth-certificate.json @@ -83,11 +83,7 @@ "name": "postalCode", "type": "string", "label": "Postal Code", - "required": false, - "validations": { - "regex": "^BB\\d{5}$", - "message": "Enter a valid postal code (e.g., BB17004)" - } + "required": false }, { "name": "idNumber", From 7cbf74b41fd82ce596c34e915ea8abd156e102bd Mon Sep 17 00:00:00 2001 From: Akinola Raphael Date: Wed, 21 Jan 2026 15:54:49 +0100 Subject: [PATCH 15/49] chore: update get marriage cert --- schemas/get-marriage-certificate.json | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/schemas/get-marriage-certificate.json b/schemas/get-marriage-certificate.json index 680208c..67aa7db 100644 --- a/schemas/get-marriage-certificate.json +++ b/schemas/get-marriage-certificate.json @@ -83,11 +83,7 @@ "name": "postalCode", "type": "string", "label": "Postal Code", - "required": false, - "validations": { - "regex": "^BB\\d{5}$", - "message": "Enter a valid postal code (e.g., BB17004)" - } + "required": false }, { "name": "idNumber", From 5312295084ae78e183a4faf061c1cf41e8e0e8e5 Mon Sep 17 00:00:00 2001 From: Akinola Raphael Date: Thu, 22 Jan 2026 01:46:40 +0100 Subject: [PATCH 16/49] feat(payments): add payment confirmation emails on successful payment Send confirmation emails when payment is successfully processed via webhook: - Admin emails: sent to addresses configured in `confirmationEmailTo[]` array - Customer email: sent to address configured in `customerEmail` field Changes: - Add `confirmationEmailTo` (string[]) and `customerEmail` fields to PaymentProcessorConfig - Store email config in payment metadata for use during webhook processing - Inject EmailService into PaymentWebhookService - Send emails in processSuccessfulPayment() with submission ID in subject - Create payment-confirmation.hbs template for admin notifications - Create payment-confirmation-customer.hbs template for customer receipts --- schemas/get-birth-certificate.json | 2 + .../payment-confirmation-customer.hbs | 92 ++++++++++++++++ src/email/templates/payment-confirmation.hbs | 103 ++++++++++++++++++ src/forms/forms.service.ts | 1 + src/forms/interfaces/form-schema.interface.ts | 2 + src/payments/payment-webhook.service.ts | 90 +++++++++++++-- src/payments/payments.module.ts | 2 + .../implementations/payment.processor.ts | 11 ++ .../interfaces/processor.interface.ts | 1 + 9 files changed, 296 insertions(+), 8 deletions(-) create mode 100644 src/email/templates/payment-confirmation-customer.hbs create mode 100644 src/email/templates/payment-confirmation.hbs diff --git a/schemas/get-birth-certificate.json b/schemas/get-birth-certificate.json index 36e3217..e01ba7b 100644 --- a/schemas/get-birth-certificate.json +++ b/schemas/get-birth-certificate.json @@ -491,6 +491,8 @@ "paymentCode": "{{db:get-birth-certificate:payment_code}}", "amount": "{{formData.order.numberOfCopies * db:get-birth-certificate:payment_amount}}", "description": "Birth Certificate Processing Fee (per copy)", + "confirmationEmailTo": ["{{db:primary-school-textbook-grant:admin_email}}"], + "customerEmail": "{{formData.applicant.email}}", "required": true, "timing": "after_validation", "responseData": { diff --git a/src/email/templates/payment-confirmation-customer.hbs b/src/email/templates/payment-confirmation-customer.hbs new file mode 100644 index 0000000..1ea1ef0 --- /dev/null +++ b/src/email/templates/payment-confirmation-customer.hbs @@ -0,0 +1,92 @@ + + + + + + +
+

Payment Received

+ +
+

Thank you for your payment!

+

Your payment has been successfully processed.

+
+ +
+ Submission ID: {{submissionId}}
+ Reference Number: {{referenceNumber}}
+ Please keep these details for your records. +
+ + +
+

Payment Summary

+ + + + + + + + + + + + + + + + + + + + + + + {{#if processor}} + + + + + {{/if}} + +
Service:{{formName}}
Submission ID:{{submissionId}}
Amount Paid:${{amount}}
Payment Date:{{processedAt}}
Transaction Number:{{transactionNumber}}
Payment Method:{{processor}}
+
+ + {{#if description}} +
+

Description

+

{{description}}

+
+ {{/if}} + + + +
+ + diff --git a/src/email/templates/payment-confirmation.hbs b/src/email/templates/payment-confirmation.hbs new file mode 100644 index 0000000..caba607 --- /dev/null +++ b/src/email/templates/payment-confirmation.hbs @@ -0,0 +1,103 @@ + + + + + + +
+

Payment Confirmation

+ +
+ Payment Successful!
+ A payment has been received for the following application. +
+ + +
+

Payment Details

+ + + + + + + + + + + + + + + + + + + + + + + {{#if processor}} + + + + + {{/if}} + +
Form:{{formName}}
Reference Number:{{referenceNumber}}
Transaction Number:{{transactionNumber}}
Amount Paid:${{amount}}
Payment Date:{{processedAt}}
Payment Method:{{processor}}
+
+ + +
+

Customer Information

+ + + + + + + + + + + +
Name:{{customerName}}
Email:{{customerEmail}}
+
+ + {{#if description}} +
+

Description

+
+ {{description}} +
+
+ {{/if}} + + + +
+ + diff --git a/src/forms/forms.service.ts b/src/forms/forms.service.ts index 39d1e82..9162780 100644 --- a/src/forms/forms.service.ts +++ b/src/forms/forms.service.ts @@ -171,6 +171,7 @@ export class FormsService { formId, submissionId, data, + formName: formSchema.name, }, ); diff --git a/src/forms/interfaces/form-schema.interface.ts b/src/forms/interfaces/form-schema.interface.ts index b0ce0b9..328c60a 100644 --- a/src/forms/interfaces/form-schema.interface.ts +++ b/src/forms/interfaces/form-schema.interface.ts @@ -67,6 +67,8 @@ export interface PaymentProcessorConfig extends ProcessorConfig { required?: boolean; // Whether payment is mandatory for form submission timing?: 'immediate' | 'after_validation'; // When to create payment responseData?: ResponseDataConfig; // Additional data to include in response + confirmationEmailTo?: string[]; // Email addresses to send payment confirmation to (supports expressions) + customerEmail?: string; // Customer email expression to send payment confirmation to (supports expressions) }; } diff --git a/src/payments/payment-webhook.service.ts b/src/payments/payment-webhook.service.ts index 5c97a1b..2bfc371 100644 --- a/src/payments/payment-webhook.service.ts +++ b/src/payments/payment-webhook.service.ts @@ -16,6 +16,7 @@ import { VerifyPaymentResult, EZPayVerifyResponse, } from './ezpay/interfaces'; +import { EmailService } from '../email/email.service'; @Injectable() export class PaymentWebhookService { @@ -29,6 +30,7 @@ export class PaymentWebhookService { @InjectRepository(FormSubmissionPayment) private formSubmissionPaymentRepository: Repository, private ezpayService: EZPayService, + private emailService: EmailService, ) {} /** @@ -315,20 +317,92 @@ export class PaymentWebhookService { }, ); - // Trigger additional workflows like: - // - Send payment confirmation emails - // - Update application status - // - Trigger document generation - // - Send notifications to relevant departments - // - Update external systems + // Send payment confirmation email if confirmationEmailTo is configured + await this.sendPaymentConfirmationEmail(payment, callbackData); - // For now, we'll just mark notification as needed + // Mark notification as sent await this.formSubmissionPaymentRepository.update( { paymentId: payment.id }, - { notificationSent: false }, // This can be picked up by a notification job + { notificationSent: true }, ); } + /** + * Send payment confirmation emails to admin and customer + */ + private async sendPaymentConfirmationEmail( + payment: Payment, + callbackData: EZPayCallbackDto, + ): Promise { + const adminEmails: string[] = payment.metadata?.confirmationEmailTo || []; + const customerEmail = payment.metadata?.configCustomerEmail; + const formName = payment.metadata?.formName || 'Form Submission'; + const formId = payment.metadata?.formId || ''; + const submissionId = payment.metadata?.submissionId || ''; + + const emailData = { + formName, + formId, + submissionId, + referenceNumber: payment.referenceNumber, + transactionNumber: callbackData._transaction_number, + amount: callbackData._amount, + processor: callbackData._processor, + customerName: payment.customerName, + customerEmail: payment.customerEmail, + description: payment.description, + }; + + // Send admin emails (using admin template) + const uniqueAdminEmails = [...new Set(adminEmails.filter(Boolean))]; + for (const adminEmail of uniqueAdminEmails) { + try { + await this.emailService.sendEmail({ + to: adminEmail, + subject: `Payment Confirmation - ${formName} - ${submissionId}`, + template: 'payment-confirmation', + data: emailData, + }); + + this.logger.log( + `Admin payment confirmation email sent to ${adminEmail} for payment ${payment.id}`, + ); + } catch (error) { + this.logger.error( + `Failed to send admin payment confirmation email to ${adminEmail} for payment ${payment.id}`, + { error: error.message }, + ); + } + } + + // Send customer email (using customer-friendly template) + if (customerEmail) { + try { + await this.emailService.sendEmail({ + to: customerEmail, + subject: `Payment Received - ${formName} - ${submissionId}`, + template: 'payment-confirmation-customer', + data: emailData, + }); + + this.logger.log( + `Customer payment confirmation email sent to ${customerEmail} for payment ${payment.id}`, + ); + } catch (error) { + this.logger.error( + `Failed to send customer payment confirmation email to ${customerEmail} for payment ${payment.id}`, + { error: error.message }, + ); + } + } + + if (uniqueAdminEmails.length === 0 && !customerEmail) { + this.logger.log( + `No email recipients configured for payment ${payment.id}, skipping email`, + ); + } + } + /** * Process failed payment workflows */ diff --git a/src/payments/payments.module.ts b/src/payments/payments.module.ts index c5e48a3..c53ea18 100644 --- a/src/payments/payments.module.ts +++ b/src/payments/payments.module.ts @@ -9,6 +9,7 @@ import { FormSubmissionPayment, } from '../database/entities'; import { EZPayService, PaymentsController } from './ezpay'; +import { EmailModule } from '../email/email.module'; @Module({ imports: [ @@ -17,6 +18,7 @@ import { EZPayService, PaymentsController } from './ezpay'; PaymentTransaction, FormSubmissionPayment, ]), + EmailModule, ], providers: [ PaymentIntegrationService, diff --git a/src/processors/implementations/payment.processor.ts b/src/processors/implementations/payment.processor.ts index 2fee8dd..934e4fc 100644 --- a/src/processors/implementations/payment.processor.ts +++ b/src/processors/implementations/payment.processor.ts @@ -51,6 +51,7 @@ export class PaymentProcessor implements IProcessor { formId: string; submissionId: string; data: Record; + formName?: string; }, ): Promise { const result = await this.process(context.data, config, context); @@ -66,6 +67,7 @@ export class PaymentProcessor implements IProcessor { context: { formId: string; submissionId: string; + formName?: string; }, ): Promise { try { @@ -96,6 +98,9 @@ export class PaymentProcessor implements IProcessor { customerName: customerInfo.name, formId: context.formId, submissionId: context.submissionId, + confirmationEmailTo: config.confirmationEmailTo, + configCustomerEmail: config.customerEmail, + formName: context.formName, }); // Create EZPay payment session @@ -277,6 +282,9 @@ export class PaymentProcessor implements IProcessor { customerName: string; formId: string; submissionId: string; + confirmationEmailTo?: string[]; + configCustomerEmail?: string; + formName?: string; }): Promise { // Include department in reference number for later API key resolution const referenceNumber = `${data.department.toUpperCase()}-${data.formId}-${ @@ -296,6 +304,9 @@ export class PaymentProcessor implements IProcessor { metadata: { formId: data.formId, submissionId: data.submissionId, + confirmationEmailTo: data.confirmationEmailTo, + configCustomerEmail: data.configCustomerEmail, + formName: data.formName, }, }); diff --git a/src/processors/interfaces/processor.interface.ts b/src/processors/interfaces/processor.interface.ts index f27e305..be84769 100644 --- a/src/processors/interfaces/processor.interface.ts +++ b/src/processors/interfaces/processor.interface.ts @@ -2,6 +2,7 @@ export interface ProcessorContext { formId: string; submissionId: string; data: Record; + formName?: string; } export interface IProcessor { From 8e38c9dedc12e3de14a8900e4000affc42ca1267 Mon Sep 17 00:00:00 2001 From: Akinola Raphael Date: Thu, 22 Jan 2026 01:49:04 +0100 Subject: [PATCH 17/49] chore(schemas): add payment confirmation email config to payment forms --- schemas/get-death-certificate.json | 2 ++ schemas/get-marriage-certificate.json | 2 ++ 2 files changed, 4 insertions(+) diff --git a/schemas/get-death-certificate.json b/schemas/get-death-certificate.json index 31d367a..81bd45a 100644 --- a/schemas/get-death-certificate.json +++ b/schemas/get-death-certificate.json @@ -254,6 +254,8 @@ "department": "revenue_authority", "paymentCode": "{{db:get-death-certificate:payment_code}}", "amount": "{{formData.order.numberOfCopies * db:get-death-certificate:payment_amount}}", + "confirmationEmailTo": ["{{db:get-death-certificate:admin_email}}"], + "customerEmail": "{{formData.applicant.email}}", "description": "Death Certificate Processing Fee (per copy)", "required": true, "timing": "after_validation", diff --git a/schemas/get-marriage-certificate.json b/schemas/get-marriage-certificate.json index 67aa7db..494e3e1 100644 --- a/schemas/get-marriage-certificate.json +++ b/schemas/get-marriage-certificate.json @@ -324,6 +324,8 @@ "department": "revenue_authority", "paymentCode": "{{db:get-marriage-certificate:payment_code}}", "amount": "{{formData.order.numberOfCopies * db:get-marriage-certificate:payment_amount}}", + "confirmationEmailTo": ["{{db:get-marriage-certificate:admin_email}}"], + "customerEmail": "{{formData.applicant.email}}", "description": "Marriage Certificate Processing Fee (per copy)", "required": true, "timing": "after_validation", From 824ea7478fc2958be0d1df0444a55a3dd1d05c35 Mon Sep 17 00:00:00 2001 From: Akinola Raphael Date: Thu, 22 Jan 2026 09:43:48 +0100 Subject: [PATCH 18/49] chore: update confirmationEmailTo --- schemas/get-birth-certificate.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/schemas/get-birth-certificate.json b/schemas/get-birth-certificate.json index e01ba7b..9bd585c 100644 --- a/schemas/get-birth-certificate.json +++ b/schemas/get-birth-certificate.json @@ -491,7 +491,7 @@ "paymentCode": "{{db:get-birth-certificate:payment_code}}", "amount": "{{formData.order.numberOfCopies * db:get-birth-certificate:payment_amount}}", "description": "Birth Certificate Processing Fee (per copy)", - "confirmationEmailTo": ["{{db:primary-school-textbook-grant:admin_email}}"], + "confirmationEmailTo": ["{{db:get-birth-certificate:admin_email}}"], "customerEmail": "{{formData.applicant.email}}", "required": true, "timing": "after_validation", From 03bfa51e839a9e0dba6235eea8d72c160099d4d5 Mon Sep 17 00:00:00 2001 From: Akinola Raphael Date: Thu, 22 Jan 2026 11:13:06 +0100 Subject: [PATCH 19/49] chore: payment reconsilation cron --- package-lock.json | 49 +++ package.json | 1 + schemas/get-birth-certificate.json | 2 +- schemas/get-death-certificate.json | 2 +- schemas/get-marriage-certificate.json | 2 +- .../ezpay/interfaces/ezpay.interface.ts | 2 +- .../payment-reconciliation.service.ts | 315 ++++++++++++++++++ src/payments/payments.module.ts | 5 + 8 files changed, 374 insertions(+), 4 deletions(-) create mode 100644 src/payments/payment-reconciliation.service.ts diff --git a/package-lock.json b/package-lock.json index 890e7e6..894591a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@nestjs/config": "^4.0.2", "@nestjs/core": "^9.0.0", "@nestjs/platform-express": "^9.0.0", + "@nestjs/schedule": "^3.0.4", "@nestjs/typeorm": "^11.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", @@ -3725,6 +3726,32 @@ "@nestjs/core": "^9.0.0" } }, + "node_modules/@nestjs/schedule": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-3.0.4.tgz", + "integrity": "sha512-uFJpuZsXfpvgx2y7/KrIZW9e1L68TLiwRodZ6+Gc8xqQiHSUzAVn+9F4YMxWFlHITZvvkjWziUFgRNCitDcTZQ==", + "dependencies": { + "cron": "2.4.3", + "uuid": "9.0.1" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0", + "reflect-metadata": "^0.1.12" + } + }, + "node_modules/@nestjs/schedule/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@nestjs/schematics": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-9.2.0.tgz", @@ -5016,6 +5043,11 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, + "node_modules/@types/luxon": { + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.3.8.tgz", + "integrity": "sha512-jYvz8UMLDgy3a5SkGJne8H7VA7zPV2Lwohjx0V8V31+SqAjNmurWMkk9cQhfvlcnXWudBpK9xPM1n4rljOcHYQ==" + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -6519,6 +6551,15 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "dev": true }, + "node_modules/cron": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/cron/-/cron-2.4.3.tgz", + "integrity": "sha512-YBvExkQYF7w0PxyeFLRyr817YVDhGxaCi5/uRRMqa4aWD3IFKRd+uNbpW1VWMdqQy8PZ7CElc+accXJcauPKzQ==", + "dependencies": { + "@types/luxon": "~3.3.0", + "luxon": "~3.3.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -9130,6 +9171,14 @@ "yallist": "^3.0.2" } }, + "node_modules/luxon": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.3.0.tgz", + "integrity": "sha512-An0UCfG/rSiqtAIiBPO0Y9/zAnHUZxAMiCpTd5h2smgsj7GGmcenvrvww2cqNA8/4A5ZrD1gJpHN2mIHZQF+Mg==", + "engines": { + "node": ">=12" + } + }, "node_modules/macos-release": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/macos-release/-/macos-release-2.5.1.tgz", diff --git a/package.json b/package.json index 96a81e6..fd3f78a 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@nestjs/config": "^4.0.2", "@nestjs/core": "^9.0.0", "@nestjs/platform-express": "^9.0.0", + "@nestjs/schedule": "^3.0.4", "@nestjs/typeorm": "^11.0.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", diff --git a/schemas/get-birth-certificate.json b/schemas/get-birth-certificate.json index 9bd585c..fce381b 100644 --- a/schemas/get-birth-certificate.json +++ b/schemas/get-birth-certificate.json @@ -487,7 +487,7 @@ "type": "payment", "config": { "provider": "ezpay", - "department": "revenue_authority", + "department": "oag_registration", "paymentCode": "{{db:get-birth-certificate:payment_code}}", "amount": "{{formData.order.numberOfCopies * db:get-birth-certificate:payment_amount}}", "description": "Birth Certificate Processing Fee (per copy)", diff --git a/schemas/get-death-certificate.json b/schemas/get-death-certificate.json index 81bd45a..0bd4be0 100644 --- a/schemas/get-death-certificate.json +++ b/schemas/get-death-certificate.json @@ -251,7 +251,7 @@ "type": "payment", "config": { "provider": "ezpay", - "department": "revenue_authority", + "department": "oag_registration", "paymentCode": "{{db:get-death-certificate:payment_code}}", "amount": "{{formData.order.numberOfCopies * db:get-death-certificate:payment_amount}}", "confirmationEmailTo": ["{{db:get-death-certificate:admin_email}}"], diff --git a/schemas/get-marriage-certificate.json b/schemas/get-marriage-certificate.json index 494e3e1..8c91249 100644 --- a/schemas/get-marriage-certificate.json +++ b/schemas/get-marriage-certificate.json @@ -321,7 +321,7 @@ "type": "payment", "config": { "provider": "ezpay", - "department": "revenue_authority", + "department": "oag_registration", "paymentCode": "{{db:get-marriage-certificate:payment_code}}", "amount": "{{formData.order.numberOfCopies * db:get-marriage-certificate:payment_amount}}", "confirmationEmailTo": ["{{db:get-marriage-certificate:admin_email}}"], diff --git a/src/payments/ezpay/interfaces/ezpay.interface.ts b/src/payments/ezpay/interfaces/ezpay.interface.ts index 0c28271..4d903c4 100644 --- a/src/payments/ezpay/interfaces/ezpay.interface.ts +++ b/src/payments/ezpay/interfaces/ezpay.interface.ts @@ -79,7 +79,7 @@ export interface EZPayTransaction { DateSettled: string; Details: string; Total: string; - Cart: unknown[]; + Cart: EZPayCartItem[]; } // Create payment params diff --git a/src/payments/payment-reconciliation.service.ts b/src/payments/payment-reconciliation.service.ts new file mode 100644 index 0000000..ba5e086 --- /dev/null +++ b/src/payments/payment-reconciliation.service.ts @@ -0,0 +1,315 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, In } from 'typeorm'; +import { Payment, PaymentStatus } from '../database/entities'; +import { EZPayService } from './ezpay/ezpay.service'; +import { PaymentWebhookService } from './payment-webhook.service'; +import { DepartmentMappingService } from './department-mapping.service'; +import { EZPayTransaction } from './ezpay/interfaces'; + +@Injectable() +export class PaymentReconciliationService { + private readonly logger = new Logger(PaymentReconciliationService.name); + private isRunning = false; + + constructor( + @InjectRepository(Payment) + private paymentRepository: Repository, + private ezpayService: EZPayService, + private paymentWebhookService: PaymentWebhookService, + private departmentMappingService: DepartmentMappingService, + ) {} + + /** + * Cron job that runs every 5 minutes to reconcile transactions + * Queries EZPay for today's transactions and triggers webhook flow for any + * transactions that need to be updated + */ + @Cron(CronExpression.EVERY_5_MINUTES) + async reconcileTransactions(): Promise { + // Prevent concurrent execution + if (this.isRunning) { + this.logger.warn( + 'Reconciliation job already running, skipping this execution', + ); + return; + } + + this.isRunning = true; + this.logger.log('Starting payment reconciliation job'); + + try { + const { startDate, endDate } = this.getTodayDateRange(); + + // Get all configured department API keys + const departments = + this.departmentMappingService.getAvailableDepartments(); + + // Build list of department queries to run in parallel + const departmentQueries: Array<{ + department: string; + apiKey: string; + }> = []; + + for (const { department, hasApiKey } of departments) { + if (!hasApiKey) { + continue; + } + + const apiKey = + this.departmentMappingService.getApiKeyForDepartment(department); + departmentQueries.push({ department, apiKey }); + } + + this.logger.log( + `Querying ${departmentQueries.length} departments in parallel`, + ); + + // Run all department queries in parallel + const results = await Promise.allSettled( + departmentQueries.map(({ department, apiKey }) => + this.reconcileDepartmentTransactions( + startDate, + endDate, + apiKey, + department, + ), + ), + ); + + // Aggregate results + let totalReconciled = 0; + let totalUpdated = 0; + + results.forEach((result, index) => { + const { department } = departmentQueries[index]; + if (result.status === 'fulfilled') { + totalReconciled += result.value.reconciled; + totalUpdated += result.value.updated; + } else { + this.logger.error( + `Failed to reconcile transactions for department: ${department}`, + { error: result.reason?.message }, + ); + } + }); + + this.logger.log( + `Payment reconciliation completed. Processed: ${totalReconciled}, Updated: ${totalUpdated}`, + ); + } catch (error) { + this.logger.error('Payment reconciliation job failed', { + error: error.message, + stack: error.stack, + }); + } finally { + this.isRunning = false; + } + } + + /** + * Reconcile transactions for a specific department/API key + */ + private async reconcileDepartmentTransactions( + startDate: string, + endDate: string, + apiKey: string, + department: string, + ): Promise<{ reconciled: number; updated: number }> { + this.logger.log(`Querying transactions for department: ${department}`); + + const queryResult = await this.ezpayService.queryTransactions( + startDate, + endDate, + apiKey, + ); + + if (!queryResult.success || !queryResult.data) { + this.logger.warn( + `Failed to query transactions for department ${department}: ${queryResult.error}`, + ); + return { reconciled: 0, updated: 0 }; + } + + const transactions = queryResult.data; + this.logger.log( + `Found ${transactions.length} transactions for department ${department}`, + ); + + let reconciled = 0; + let updated = 0; + + for (const transaction of transactions) { + reconciled++; + + try { + const wasUpdated = await this.processTransaction(transaction); + if (wasUpdated) { + updated++; + } + } catch (error) { + this.logger.error( + `Failed to process transaction ${transaction.TransactionCode}`, + { error: error.message }, + ); + } + } + + return { reconciled, updated }; + } + + /** + * Process a single transaction from EZPay query + * Returns true if the payment was updated + */ + private async processTransaction( + transaction: EZPayTransaction, + ): Promise { + const transactionNumber = transaction.TransactionCode; + const ezpayStatus = transaction.Status; + + // Extract reference from transaction details if available + const reference = this.extractReferenceFromTransaction(transaction); + + if (!reference) { + this.logger.debug( + `No reference found for transaction ${transactionNumber}, skipping`, + ); + return false; + } + + // Find the payment in our database + const payment = await this.paymentRepository.findOne({ + where: { referenceNumber: reference }, + }); + + if (!payment) { + this.logger.debug( + `Payment not found for reference ${reference}, skipping`, + ); + return false; + } + + // Check if status needs to be updated + const currentStatus = payment.status; + const newStatus = this.mapEZPayStatusToPaymentStatus(ezpayStatus); + + // Only process if status is different and payment is not already in final state + if (currentStatus === newStatus) { + return false; + } + + // Skip if payment is already in a final successful state + if ( + currentStatus === PaymentStatus.SUCCESS && + newStatus !== PaymentStatus.REFUNDED + ) { + return false; + } + + this.logger.log( + `Reconciling payment ${payment.id}: ${currentStatus} -> ${newStatus}`, + { + transactionNumber, + reference, + }, + ); + + // Use manual verification to update the payment and trigger workflows + const result = await this.paymentWebhookService.manualPaymentVerification( + transactionNumber, + reference, + ); + + if (result.success) { + this.logger.log(`Successfully reconciled payment ${payment.id}`); + return true; + } else { + this.logger.warn( + `Failed to reconcile payment ${payment.id}: ${result.message}`, + ); + return false; + } + } + + /** + * Extract reference number from EZPay transaction + * The reference is typically stored in the Cart items as JSON strings + */ + private extractReferenceFromTransaction( + transaction: EZPayTransaction, + ): string | null { + // Try to extract from Cart items if available + if (transaction.Cart && Array.isArray(transaction.Cart)) { + for (const item of transaction.Cart) { + try { + // Cart items may be JSON strings that need parsing + const cartItem = typeof item === 'string' ? JSON.parse(item) : item; + if (cartItem.reference && typeof cartItem.reference === 'string') { + return cartItem.reference; + } + } catch { + // Skip items that can't be parsed + continue; + } + } + } + + return null; + } + + /** + * Map EZPay status to internal payment status + */ + private mapEZPayStatusToPaymentStatus(ezpayStatus: string): PaymentStatus { + switch (ezpayStatus) { + case 'Success': + return PaymentStatus.SUCCESS; + case 'Failed': + return PaymentStatus.FAILED; + case 'Initiated': + return PaymentStatus.INITIATED; + default: + return PaymentStatus.PENDING; + } + } + + /** + * Get today's date range in YYYY-MM-DD HH:mm format + */ + private getTodayDateRange(): { startDate: string; endDate: string } { + const today = new Date(); + const year = today.getFullYear(); + const month = String(today.getMonth() + 1).padStart(2, '0'); + const day = String(today.getDate()).padStart(2, '0'); + const dateStr = `${year}-${month}-${day}`; + return { + startDate: `${dateStr} 00:00`, + endDate: `${dateStr} 23:59`, + }; + } + + /** + * Manual trigger for reconciliation (can be called via API endpoint) + */ + async triggerReconciliation(): Promise<{ + success: boolean; + message: string; + }> { + if (this.isRunning) { + return { + success: false, + message: 'Reconciliation job is already running', + }; + } + + // Run in background + this.reconcileTransactions(); + + return { + success: true, + message: 'Reconciliation job triggered', + }; + } +} diff --git a/src/payments/payments.module.ts b/src/payments/payments.module.ts index c53ea18..a5853a6 100644 --- a/src/payments/payments.module.ts +++ b/src/payments/payments.module.ts @@ -1,7 +1,9 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { ScheduleModule } from '@nestjs/schedule'; import { PaymentIntegrationService } from './payment-integration.service'; import { PaymentWebhookService } from './payment-webhook.service'; +import { PaymentReconciliationService } from './payment-reconciliation.service'; import { DepartmentMappingService } from './department-mapping.service'; import { Payment, @@ -18,11 +20,13 @@ import { EmailModule } from '../email/email.module'; PaymentTransaction, FormSubmissionPayment, ]), + ScheduleModule.forRoot(), EmailModule, ], providers: [ PaymentIntegrationService, PaymentWebhookService, + PaymentReconciliationService, EZPayService, DepartmentMappingService, ], @@ -30,6 +34,7 @@ import { EmailModule } from '../email/email.module'; exports: [ PaymentIntegrationService, PaymentWebhookService, + PaymentReconciliationService, DepartmentMappingService, ], }) From a6702e66cf0c2d0512bfc2bde873b16adea68ede Mon Sep 17 00:00:00 2001 From: Akinola Raphael Date: Thu, 22 Jan 2026 11:18:43 +0100 Subject: [PATCH 20/49] chore: update email template --- .../payment-confirmation-customer.hbs | 69 +++---------------- src/email/templates/payment-confirmation.hbs | 54 ++++++++------- src/payments/payment-webhook.service.ts | 4 +- 3 files changed, 41 insertions(+), 86 deletions(-) diff --git a/src/email/templates/payment-confirmation-customer.hbs b/src/email/templates/payment-confirmation-customer.hbs index 1ea1ef0..bc1dc7e 100644 --- a/src/email/templates/payment-confirmation-customer.hbs +++ b/src/email/templates/payment-confirmation-customer.hbs @@ -6,84 +6,37 @@ "Helvetica Neue", Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; } .container { max-width: 600px; margin: 0 auto; padding: 20px; } h1 { color: #003087; border-bottom: 3px solid #FFB71B; - padding-bottom: 10px; margin-bottom: 20px; font-size: 24px; } h2 { color: - #003087; font-size: 18px; margin-top: 25px; margin-bottom: 12px; } - table { width: 100%; border-collapse: collapse; margin-bottom: 20px; } td - { padding: 10px 12px; border-bottom: 1px solid #eee; } td:first-child { - background-color: #f5f5f5; font-weight: bold; width: 40%; } .footer { margin-top: 30px; padding-top: 20px; + padding-bottom: 10px; margin-bottom: 20px; font-size: 24px; } + .footer { margin-top: 30px; padding-top: 20px; border-top: 1px solid #ccc; color: #666; font-size: 12px; text-align: left; } .success-banner { margin-bottom: 25px; font-size: 16px; background-color: #d4edda; padding: 20px; border-left: 4px solid #28a745; - border-radius: 4px; } .success-banner h2 { color: #155724; margin: 0 0 10px 0; font-size: 20px; } - .success-banner p { margin: 0; color: #155724; } .reference-box { + border-radius: 4px; } .success-banner p { margin: 0; color: #155724; } + .reference-box { background-color: #e7f3ff; padding: 15px; border-radius: 4px; margin: 20px 0; border: 1px solid #b8daff; } .reference-box strong { color: #004085; } - .amount-highlight { font-size: 24px; color: #28a745; font-weight: bold; }
-

Payment Received

+

Thank you for your request

-

Thank you for your payment!

-

Your payment has been successfully processed.

+

Thank you for requesting a {{formName}}.

+

Your information has been sent to the Registrations Department.

- Submission ID: {{submissionId}}
- Reference Number: {{referenceNumber}}
- Please keep these details for your records. + Reference Number: {{submissionId}}
+ Please keep this reference number for your records.
- -
-

Payment Summary

- - - - - - - - - - - - - - - - - - - - - - - {{#if processor}} - - - - - {{/if}} - -
Service:{{formName}}
Submission ID:{{submissionId}}
Amount Paid:${{amount}}
Payment Date:{{processedAt}}
Transaction Number:{{transactionNumber}}
Payment Method:{{processor}}
-
- - {{#if description}} -
-

Description

-

{{description}}

-
- {{/if}} - diff --git a/src/email/templates/payment-confirmation.hbs b/src/email/templates/payment-confirmation.hbs index caba607..8ee6548 100644 --- a/src/email/templates/payment-confirmation.hbs +++ b/src/email/templates/payment-confirmation.hbs @@ -13,19 +13,20 @@ background-color: #f5f5f5; font-weight: bold; width: 40%; } .section { margin-top: 20px; } .footer { margin-top: 30px; padding-top: 20px; border-top: 1px solid #ccc; color: #666; font-size: 12px; text-align: - left; } .success-info { margin-bottom: 20px; font-size: 16px; - background-color: #d4edda; padding: 15px; border-left: 4px solid #28a745; - border-radius: 4px; } .highlight { background-color: #fff3cd; padding: 10px; border: 1px solid - #ffeaa7; border-radius: 4px; margin: 10px 0; } + left; } .info-box { margin-bottom: 20px; font-size: 16px; + background-color: #e7f3ff; padding: 15px; border-left: 4px solid #004085; + border-radius: 4px; } .warning-box { background-color: #fff3cd; padding: 15px; border: 1px solid + #ffeaa7; border-radius: 4px; margin: 20px 0; } .reference-highlight { + font-size: 18px; font-weight: bold; color: #003087; }
-

Payment Confirmation

+

{{formName}} payment

-
- Payment Successful!
- A payment has been received for the following application. +
+

You have received payment for a {{formName}}.

+

Reference number: {{submissionId}}

@@ -34,35 +35,38 @@ - - - - - - + + - + + {{#if processor}} - - + + + {{/if}} + {{#if processedAt}} - {{#if processor}} - - - - {{/if}}
Form:{{formName}}
Reference Number:{{referenceNumber}}Payment amount received:${{amount}}
Transaction Number:EZPay transaction ID: {{transactionNumber}}
Amount Paid:${{amount}}Payment Method:{{processor}}
Payment Date: {{processedAt}}
Payment Method:{{processor}}
+ +
+ Before you can process the request for a {{formName}}, you need 2 emails with matching reference numbers in the subject lines: +
    +
  1. A payment confirmation email (this email)
  2. +
  3. A certificate request email (a separate email with the subject line: Reference number: {{submissionId}} ({{formName}} request))
  4. +
+
+

Customer Information

@@ -83,9 +87,7 @@ {{#if description}}

Description

-
- {{description}} -
+

{{description}}

{{/if}} @@ -95,7 +97,7 @@ This is an automated payment confirmation email from the Government of Barbados Forms Processing System.

Form ID: {{formId}}
- Submission ID: {{submissionId}} + Reference Number: {{submissionId}}

diff --git a/src/payments/payment-webhook.service.ts b/src/payments/payment-webhook.service.ts index 2bfc371..747c920 100644 --- a/src/payments/payment-webhook.service.ts +++ b/src/payments/payment-webhook.service.ts @@ -359,7 +359,7 @@ export class PaymentWebhookService { try { await this.emailService.sendEmail({ to: adminEmail, - subject: `Payment Confirmation - ${formName} - ${submissionId}`, + subject: `${formName} payment (reference number: ${submissionId})`, template: 'payment-confirmation', data: emailData, }); @@ -380,7 +380,7 @@ export class PaymentWebhookService { try { await this.emailService.sendEmail({ to: customerEmail, - subject: `Payment Received - ${formName} - ${submissionId}`, + subject: `Thank you for your request`, template: 'payment-confirmation-customer', data: emailData, }); From 93a36da04737d316cf729c8697b212e65b2d3633 Mon Sep 17 00:00:00 2001 From: Shannon Clarke Date: Thu, 22 Jan 2026 07:50:32 -0400 Subject: [PATCH 21/49] fix: update incorrect field labels and change required validation for email and telephone fields (#67) --- schemas/get-birth-certificate.json | 4 ++-- schemas/get-death-certificate.json | 13 +++++++++++-- src/email/templates/death-certificate.hbs | 2 +- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/schemas/get-birth-certificate.json b/schemas/get-birth-certificate.json index fce381b..f898d54 100644 --- a/schemas/get-birth-certificate.json +++ b/schemas/get-birth-certificate.json @@ -108,13 +108,13 @@ "name": "email", "type": "email", "label": "Email address", - "required": false + "required": true }, { "name": "telephoneNumber", "type": "string", "label": "Telephone number", - "required": false + "required": true } ] }, diff --git a/schemas/get-death-certificate.json b/schemas/get-death-certificate.json index 0bd4be0..7c2dc85 100644 --- a/schemas/get-death-certificate.json +++ b/schemas/get-death-certificate.json @@ -123,7 +123,7 @@ ] }, { - "name": "relationship", + "name": "relationshipToPerson", "type": "string", "label": "Tell us your relationship with the deceased", "required": true, @@ -132,7 +132,16 @@ } }, { - "name": "reasonForRequest", + "name": "relationshipOtherDescription", + "type": "string", + "label": "Please describe your relationship", + "required": false, + "validations": { + "max": 100 + } + }, + { + "name": "reasonForCertificate", "type": "string", "label": "Tell us about why you need this certificate", "required": true, diff --git a/src/email/templates/death-certificate.hbs b/src/email/templates/death-certificate.hbs index 435fefb..93b8cf1 100644 --- a/src/email/templates/death-certificate.hbs +++ b/src/email/templates/death-certificate.hbs @@ -28,7 +28,7 @@ Application Submitted: {{processedAt}}
Relationship to Deceased: - {{relationship}} + {{relationshipToPerson}}
From e2057437f2802dfe0adab1ff8f14025bb83286fb Mon Sep 17 00:00:00 2001 From: Akinola Raphael Date: Thu, 22 Jan 2026 13:03:18 +0100 Subject: [PATCH 22/49] chore: humanize summision id --- src/common/utils/index.ts | 1 + src/common/utils/reference-code.util.ts | 48 +++++++++++++++++++ src/forms/forms.service.ts | 10 ++-- src/payments/ezpay/ezpay.service.ts | 6 +-- .../implementations/payment.processor.ts | 2 +- 5 files changed, 58 insertions(+), 9 deletions(-) create mode 100644 src/common/utils/index.ts create mode 100644 src/common/utils/reference-code.util.ts diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts new file mode 100644 index 0000000..723b010 --- /dev/null +++ b/src/common/utils/index.ts @@ -0,0 +1 @@ +export * from './reference-code.util'; diff --git a/src/common/utils/reference-code.util.ts b/src/common/utils/reference-code.util.ts new file mode 100644 index 0000000..1b461c1 --- /dev/null +++ b/src/common/utils/reference-code.util.ts @@ -0,0 +1,48 @@ +/** + * Generates a human-friendly reference code in the format: PREFIX-YYYYMMDD-XXXXXX + * Example: GDC-20260122-A3B7K9 + * + * @param formId - The form ID (e.g., "get-death-certificate") + * @returns A human-friendly reference code + */ +export function generateReferenceCode(formId: string, length = 6): string { + const prefix = generatePrefix(formId); + const dateStr = formatDate(new Date()); + const randomPart = generateRandomString(length); + + return `${prefix}-${dateStr}-${randomPart}`; +} + +/** + * Generates a prefix from the form ID by taking the first letter of each word + * Example: "get-death-certificate" => "GDC" + */ +function generatePrefix(formId: string): string { + return formId + .split('-') + .map((word) => word.charAt(0).toUpperCase()) + .join(''); +} + +/** + * Formats a date as YYYYMMDD + */ +function formatDate(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}${month}${day}`; +} + +/** + * Generates a random alphanumeric string of the specified length + * Uses uppercase letters and digits for readability + */ +function generateRandomString(length: number): string { + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Excluded I, O, 0, 1 to avoid confusion + let result = ''; + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; +} diff --git a/src/forms/forms.service.ts b/src/forms/forms.service.ts index 9162780..5e5075d 100644 --- a/src/forms/forms.service.ts +++ b/src/forms/forms.service.ts @@ -1,11 +1,11 @@ import { Injectable, Logger } from '@nestjs/common'; -import { v4 as uuidv4 } from 'uuid'; import { FormSchema } from './interfaces'; import { SchemaBuilderService } from '../validation/schema-builder.service'; import { ProcessorPipelineService } from '../processors/processor-pipeline.service'; import { FormSubmissionResponseDto } from './dto'; import { FormUtilsService } from './form-utils.service'; import { OpenCRVSProcessorResult } from '../opencrvs/types'; +import { generateReferenceCode } from '../common/utils'; @Injectable() export class FormsService { @@ -58,7 +58,7 @@ export class FormsService { } // Generate submission ID - const submissionId = uuidv4(); + const submissionId = generateReferenceCode(formId); this.logger.log(`Processing form submission: ${formId} (${submissionId})`); @@ -116,9 +116,9 @@ export class FormsService { private buildIntegrationsResult( processorResults: Map, ): Record | undefined { - const opencrvsResult = processorResults.get( - 'opencrvs', - ) as OpenCRVSProcessorResult | undefined; + const opencrvsResult = processorResults.get('opencrvs') as + | OpenCRVSProcessorResult + | undefined; if (!opencrvsResult) { return undefined; diff --git a/src/payments/ezpay/ezpay.service.ts b/src/payments/ezpay/ezpay.service.ts index aa613e8..821fd2c 100644 --- a/src/payments/ezpay/ezpay.service.ts +++ b/src/payments/ezpay/ezpay.service.ts @@ -62,17 +62,17 @@ export class EZPayService { /** * Extract department from reference number - * Expected format: DEPARTMENT-formId-submissionId (e.g., EDUCATION-form123-sub456) + * Expected format: DEPARTMENT_formId_submissionId (e.g., EDUCATION_form123_sub456) */ private extractDepartmentFromReference(reference: string): string | null { // Check if reference follows the department format - const match = reference.match(/^([A-Z_]+)-(.+)-(.+)$/); + const match = reference.match(/^([A-Z_]+)_(.+)_(.+)$/); if (match) { return match[1].toLowerCase(); // Convert EDUCATION to education } this.logger.warn( - `Reference ${reference} does not follow expected format DEPARTMENT-formId-submissionId. Using default API key.`, + `Reference ${reference} does not follow expected format DEPARTMENT_formId_submissionId. Using default API key.`, ); return null; } diff --git a/src/processors/implementations/payment.processor.ts b/src/processors/implementations/payment.processor.ts index 934e4fc..13131e4 100644 --- a/src/processors/implementations/payment.processor.ts +++ b/src/processors/implementations/payment.processor.ts @@ -287,7 +287,7 @@ export class PaymentProcessor implements IProcessor { formName?: string; }): Promise { // Include department in reference number for later API key resolution - const referenceNumber = `${data.department.toUpperCase()}-${data.formId}-${ + const referenceNumber = `${data.department.toUpperCase()}_${data.formId}_${ data.submissionId }`; From abe24ef9a67ced74a053178a3ddac545c8cce59e Mon Sep 17 00:00:00 2001 From: Akinola Raphael Date: Thu, 22 Jan 2026 13:07:11 +0100 Subject: [PATCH 23/49] chore: email template submission ref --- .../templates/apply-for-conductor-licence.hbs | 2 ++ src/email/templates/birth-certificate.hbs | 2 ++ src/email/templates/birth-registration.hbs | 2 ++ .../community-sports-registration.hbs | 3 ++- src/email/templates/death-certificate.hbs | 2 ++ src/email/templates/exit-survey.hbs | 2 ++ src/email/templates/feedback-submission.hbs | 2 +- .../templates/jobstart-plus-programme.hbs | 3 ++- src/email/templates/marriage-certificate.hbs | 2 ++ .../templates/permission-to-remove-tree.hbs | 1 + .../post-office-redirection-notice.hbs | 18 ++++++++++++++---- .../primary-school-textbook-grant.hbs | 1 + src/email/templates/project-protege-mentor.hbs | 1 + src/email/templates/reserve-company-name.hbs | 1 + src/email/templates/reserve-society-name.hbs | 1 + .../sell-goods-services-beach-park-notice.hbs | 1 + 16 files changed, 37 insertions(+), 7 deletions(-) diff --git a/src/email/templates/apply-for-conductor-licence.hbs b/src/email/templates/apply-for-conductor-licence.hbs index 11f934f..94fb2ed 100644 --- a/src/email/templates/apply-for-conductor-licence.hbs +++ b/src/email/templates/apply-for-conductor-licence.hbs @@ -27,6 +27,7 @@

A new conductor licence application has been submitted.

+

Reference: {{submissionId}}

Applicant Information
@@ -156,6 +157,7 @@ diff --git a/src/email/templates/birth-certificate.hbs b/src/email/templates/birth-certificate.hbs index 5e554e3..278bdf8 100644 --- a/src/email/templates/birth-certificate.hbs +++ b/src/email/templates/birth-certificate.hbs @@ -25,6 +25,8 @@

New Birth Certificate Application