From f75527e04594296106028a963dc4a975534b80c4 Mon Sep 17 00:00:00 2001 From: Isaiah <63256944+IsaiahSama@users.noreply.github.com> Date: Fri, 30 Jan 2026 11:33:02 -0400 Subject: [PATCH 1/7] Fixed issue where min was making an optional field required (#88) --- schemas/sell-goods-services-beach-park.json | 1 - 1 file changed, 1 deletion(-) diff --git a/schemas/sell-goods-services-beach-park.json b/schemas/sell-goods-services-beach-park.json index 95f52e2..9b1e57d 100644 --- a/schemas/sell-goods-services-beach-park.json +++ b/schemas/sell-goods-services-beach-park.json @@ -184,7 +184,6 @@ "value": "imported" }, "validations": { - "min": 2, "max": 100, "message": "Location must be at least 2 characters" } From a2805153d48bac4d2827144714963841d64fcab6 Mon Sep 17 00:00:00 2001 From: Ihtisham Tanveer <84310367+ihtishamtanveer@users.noreply.github.com> Date: Fri, 30 Jan 2026 20:38:59 +0500 Subject: [PATCH 2/7] Handle validations dynamically (#70) * feat: implement conditional required and validation logic for form fields * feat: enhance validation logic for father and mother fields in birth registration form * feat: implement dynamic validation messages and health facility constants for OpenCRVS integration * feat: change age field type to string and update validation logic for age range in register birth form * feat: remove localhost URL handling from OpenCRVS service configuration --------- Co-authored-by: Akinola Raphael <54055273+Ethical-Ralph@users.noreply.github.com> --- schemas/register-birth-form.json | 157 +++++++++- src/config/configuration.ts | 3 - src/forms/forms.service.ts | 14 +- src/forms/interfaces/form-schema.interface.ts | 40 ++- src/opencrvs/common/opencrvs.constants.ts | 26 ++ src/opencrvs/common/opencrvs.utils.ts | 12 + src/opencrvs/opencrvs.service.ts | 44 ++- src/opencrvs/types/opencrvs.types.ts | 11 +- .../implementations/opencrvs.processor.ts | 30 +- src/validation/schema-builder.service.ts | 277 +++++++++++++++++- 10 files changed, 549 insertions(+), 65 deletions(-) create mode 100644 src/opencrvs/common/opencrvs.constants.ts create mode 100644 src/opencrvs/common/opencrvs.utils.ts diff --git a/schemas/register-birth-form.json b/schemas/register-birth-form.json index ed7e245..78986ff 100644 --- a/schemas/register-birth-form.json +++ b/schemas/register-birth-form.json @@ -24,7 +24,18 @@ { "name": "father", "type": "object", - "required": false, + "required": { + "when": { + "all": [ + { + "field": "includeFatherDetails", + "operator": "equals", + "value": "yes" + } + ] + }, + "message": "Father details are required when includeFatherDetails is yes" + }, "fields": [ { "name": "firstName", @@ -52,6 +63,27 @@ "max": 100 } }, + { + "name": "age", + "type": "string", + "required": { + "when": { + "all": [{ "field": "father.idNumber", "operator": "empty" }] + }, + "message": "Age is required when no ID number is provided" + }, + "validations": { + "condition": { + "field": "father.age", + "operator": "notIn", + "value": [null, ""], + "then": { + "regex": "^(1[2-9]|[2-9][0-9]|1[0-1][0-9]|120)$", + "message": "Age should be between 12 and 120" + } + } + } + }, { "name": "parish", "type": "string", @@ -90,9 +122,23 @@ { "name": "passportNumber", "type": "string", - "required": false, + "required": { + "when": { + "all": [{ "field": "father.idNumber", "operator": "empty" }] + }, + "message": "Passport number is required when no ID number is provided" + }, "validations": { - "max": 50 + "condition": { + "field": "father.passportNumber", + "operator": "notIn", + "value": [null, ""], + "then": { + "min": 6, + "max": 50, + "message": "Passport number must be between 6 and 50 characters" + } + } } }, { @@ -120,17 +166,79 @@ "message": "Must select an option" } }, + { + "name": "healthFacility", + "type": "string", + "label": "Health Facility", + "required": { + "when": { + "all": [ + { + "field": "birth.placeOfBirth", + "operator": "equals", + "value": "health-facility" + } + ] + }, + "message": "Health facility is required when place of birth is health-facility" + } + }, { "name": "parish", "type": "string", "label": "Parish", - "required": false + "required": { + "when": { + "all": [ + { + "field": "birth.placeOfBirth", + "operator": "in", + "value": ["residential", "other"] + } + ] + }, + "message": "Parish is required for residential/other birth locations" + }, + "validations": { + "condition": { + "field": "birth.parish", + "operator": "notIn", + "value": [null, ""], + "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": false + "required": { + "when": { + "all": [ + { + "field": "birth.placeOfBirth", + "operator": "in", + "value": ["residential", "other"] + } + ] + }, + "message": "Street address is required for residential/other birth locations" + }, + "validations": { + "condition": { + "field": "birth.streetAddress", + "operator": "notIn", + "value": [null, ""], + "then": { + "min": 5, + "max": 200, + "message": "Address must be at least 5 characters" + } + } + } }, { "name": "numberOfBirths", @@ -203,6 +311,27 @@ "max": 100 } }, + { + "name": "age", + "type": "string", + "required": { + "when": { + "all": [{ "field": "mother.idNumber", "operator": "empty" }] + }, + "message": "Age is required when no ID number is provided" + }, + "validations": { + "condition": { + "field": "mother.age", + "operator": "notIn", + "value": [null, ""], + "then": { + "regex": "^(1[2-9]|[2-9][0-9]|1[0-1][0-9]|120)$", + "message": "Age should be between 12 and 120" + } + } + } + }, { "name": "maidenSurname", "type": "string", @@ -249,9 +378,23 @@ { "name": "passportNumber", "type": "string", - "required": false, + "required": { + "when": { + "all": [{ "field": "mother.idNumber", "operator": "empty" }] + }, + "message": "Passport number is required when no ID number is provided" + }, "validations": { - "max": 50 + "condition": { + "field": "mother.passportNumber", + "operator": "notIn", + "value": [null, ""], + "then": { + "min": 6, + "max": 50, + "message": "Passport number must be between 6 and 50 characters" + } + } } }, { diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 9991e80..92eed15 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -58,9 +58,6 @@ export default () => ({ }, }, 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: diff --git a/src/forms/forms.service.ts b/src/forms/forms.service.ts index cc34599..dd362d9 100644 --- a/src/forms/forms.service.ts +++ b/src/forms/forms.service.ts @@ -6,6 +6,10 @@ import { FormSubmissionResponseDto } from './dto'; import { FormUtilsService } from './form-utils.service'; import { OpenCRVSProcessorResult } from '../opencrvs/types'; import { generateReferenceCode } from '../common/utils'; +import { + getOpenCRVSSuccessMessage, + getOpenCRVSFailureMessage, +} from '../opencrvs/common/opencrvs.utils'; @Injectable() export class FormsService { @@ -94,7 +98,10 @@ export class FormsService { ); // Build integrations result from processor results - const additionalData = this.buildIntegrationsResult(processorResults); + const additionalData = this.buildIntegrationsResult( + processorResults, + formId, + ); const response = new FormSubmissionResponseDto( submissionId, @@ -115,6 +122,7 @@ export class FormsService { */ private buildIntegrationsResult( processorResults: Map, + formId: string, ): Record | undefined { const opencrvsResult = processorResults.get('opencrvs') as | OpenCRVSProcessorResult @@ -128,12 +136,12 @@ export class FormsService { opencrvs: opencrvsResult.success ? { success: true, - message: 'Birth registration submitted successfully', + message: getOpenCRVSSuccessMessage(formId), trackingId: opencrvsResult.trackingId, } : { success: false, - message: opencrvsResult.error ?? 'Birth registration failed', + message: opencrvsResult.error ?? getOpenCRVSFailureMessage(formId), }, }; diff --git a/src/forms/interfaces/form-schema.interface.ts b/src/forms/interfaces/form-schema.interface.ts index 436ab49..20ca1d7 100644 --- a/src/forms/interfaces/form-schema.interface.ts +++ b/src/forms/interfaces/form-schema.interface.ts @@ -10,7 +10,7 @@ export interface FormField { name: string; type: FieldType; label?: string; - required?: boolean; + required?: boolean | ConditionalRequired; placeholder?: string; defaultValue?: any; validations?: FieldValidation; @@ -35,12 +35,26 @@ export type FieldType = | 'object' | 'array'; +interface SubFieldValidation { + min?: number; + max?: number; + regex?: string; + message?: string; +} + export interface FieldValidation { min?: number; // For numbers and string length max?: number; // For numbers and string length email?: boolean; regex?: string; message?: string; // Custom error message + condition?: { + field: string; // Dependent field path + operator?: 'equals' | 'not_equals' | 'in' | 'not_in'; // Default is 'equals' + value: any[]; // Values that trigger the validation + then: SubFieldValidation; // Validation to apply if condition is met + else?: SubFieldValidation; // Optional validation if condition is not met + }; } export interface FieldOption { @@ -48,6 +62,30 @@ export interface FieldOption { value: string | number; } +export interface ConditionalRequired { + when: ConditionalWhen; + message?: string; // Custom error message when field is required +} + +export interface ConditionalWhen { + all?: ConditionalRule[]; + any?: ConditionalRule[]; +} +export interface ConditionalRule { + field: string; // Dependent field path (e.g., "father.idNumber") + operator: + | 'exists' + | 'missing' + | 'null' + | 'empty' + | 'notEmpty' + | 'equals' + | 'notEquals' + | 'in' + | 'notIn'; + value?: any; // Value for equals, notEquals, in, notIn operators +} + export interface ProcessorConfig { type: string; config: Record; diff --git a/src/opencrvs/common/opencrvs.constants.ts b/src/opencrvs/common/opencrvs.constants.ts new file mode 100644 index 0000000..ebb169b --- /dev/null +++ b/src/opencrvs/common/opencrvs.constants.ts @@ -0,0 +1,26 @@ +import { HealthFacility } from '../types'; + +export const HEALTH_FACILITIES: HealthFacility[] = [ + { + label: 'Queen Elizabeth Hospital', + value: '3d5cd721-df37-493c-86c0-41b8aa42e27d', + }, + { + label: 'Bayview Hospital', + value: '9ddfdd4a-4219-4ca0-ad34-5a9fc1071225', + }, + { + label: 'MD Alliance Surgery and Birthing Centre', + value: 'a1abb507-4a25-4795-a280-c99226cb916f', + }, +]; + +export const OPENCRVS_FORM_MESSAGES: Record< + string, + { success: string; failure: string } +> = { + 'register-birth-form': { + success: 'Birth registration submitted successfully', + failure: 'Birth registration failed', + }, +}; diff --git a/src/opencrvs/common/opencrvs.utils.ts b/src/opencrvs/common/opencrvs.utils.ts new file mode 100644 index 0000000..7a9329c --- /dev/null +++ b/src/opencrvs/common/opencrvs.utils.ts @@ -0,0 +1,12 @@ +import { OPENCRVS_FORM_MESSAGES } from './opencrvs.constants'; + +export function getOpenCRVSSuccessMessage(formId: string): string { + return ( + OPENCRVS_FORM_MESSAGES[formId]?.success || + 'Registration submitted successfully' + ); +} + +export function getOpenCRVSFailureMessage(formId: string): string { + return OPENCRVS_FORM_MESSAGES[formId]?.failure || 'Registration failed'; +} diff --git a/src/opencrvs/opencrvs.service.ts b/src/opencrvs/opencrvs.service.ts index f57dd96..30ab362 100644 --- a/src/opencrvs/opencrvs.service.ts +++ b/src/opencrvs/opencrvs.service.ts @@ -23,30 +23,26 @@ export class OpenCRVSService { private readonly clientId: string; private readonly clientSecret: string; - constructor( - private readonly configService: ConfigService, - private readonly cacheService: OpenCRVSCacheService, - ) { - 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', - ); - } + // 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) { + 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( diff --git a/src/opencrvs/types/opencrvs.types.ts b/src/opencrvs/types/opencrvs.types.ts index e3d3f26..02a3a4d 100644 --- a/src/opencrvs/types/opencrvs.types.ts +++ b/src/opencrvs/types/opencrvs.types.ts @@ -135,7 +135,7 @@ export type BirthDeclaration = { 'mother.brn'?: string; 'mother.address'?: DomesticAddress; 'mother.occupation'?: string; - 'mother.bornAlive'?: number + 'mother.bornAlive'?: number; 'mother.stillborn'?: number; 'mother.stillAlive'?: number; @@ -237,3 +237,12 @@ export type OpenCRVSProcessorResult = { transactionId?: string; error?: string; }; + +// ============================================================================ +// Exported Constants +// ============================================================================ + +export type HealthFacility = { + label: string; + value: string; +}; diff --git a/src/processors/implementations/opencrvs.processor.ts b/src/processors/implementations/opencrvs.processor.ts index 3a754eb..245997b 100644 --- a/src/processors/implementations/opencrvs.processor.ts +++ b/src/processors/implementations/opencrvs.processor.ts @@ -5,6 +5,7 @@ import { OpenCRVSProcessorConfig, OpenCRVSProcessorResult, } from '../../opencrvs/types'; +import { HEALTH_FACILITIES } from '../../opencrvs/common/opencrvs.constants'; /** * Processor for integrating birth registration form submissions with OpenCRVS @@ -45,27 +46,20 @@ export class OpenCRVSProcessor implements IProcessor { ); } - const healthFacilityList = [{ - label: "Queen Elizabeth Hospital", - value: "3d5cd721-df37-493c-86c0-41b8aa42e27d", - }, - { - label: "Bayview Hospital", - value: "9ddfdd4a-4219-4ca0-ad34-5a9fc1071225", - }, - { - label: "MD Alliance Surgery and Birthing Centre", - value: "a1abb507-4a25-4795-a280-c99226cb916f", - }]; - - const healthFacilityName = healthFacilityList.find(hf => hf.value === context?.data?.birth?.healthFacility)?.label || ''; + const healthFacilityName = + HEALTH_FACILITIES.find( + (hf) => hf.value === context?.data?.birth?.healthFacility, + )?.label || ''; // Resolve location IDs const { officeId, healthFacilityId, parishId } = - await this.resolveLocations({ - ...processorConfig, - healthFacilityName, - }, context.data); + await this.resolveLocations( + { + ...processorConfig, + healthFacilityName, + }, + context.data, + ); // Map form data to OpenCRVS declaration format // Note: Informant fields are not required per OpenCRVS Barbados configuration diff --git a/src/validation/schema-builder.service.ts b/src/validation/schema-builder.service.ts index 6659dce..3e39980 100644 --- a/src/validation/schema-builder.service.ts +++ b/src/validation/schema-builder.service.ts @@ -1,6 +1,13 @@ import { Injectable, BadRequestException } from '@nestjs/common'; import { z, ZodSchema, ZodError } from 'zod'; -import { FormSchema, FormField, FieldValidation } from '../forms/interfaces'; +import { + FormSchema, + FormField, + FieldValidation, + ConditionalRule, + ConditionalWhen, + ConditionalRequired, +} from '../forms/interfaces'; @Injectable() export class SchemaBuilderService { @@ -11,7 +18,261 @@ export class SchemaBuilderService { shape[field.name] = this.buildFieldSchema(field); } - return z.object(shape); + const { dynamicallyRequired, dynamicallyValidated } = + this.collectConditionalFields(formSchema.fields); + + return z.object(shape).superRefine((data, ctx) => { + /* ----------------------------- + * CONDITIONAL REQUIRED + * ----------------------------- */ + for (const { field, path } of dynamicallyRequired) { + const requiredCondition = field.required as ConditionalRequired; + const currentValue = this.getValueByPath(data, path.join('.')); + + // Skip validation if parent object doesn't exist + if (!this.shouldValidateConditional(data, path)) continue; + + const conditionMet = this.evaluateWhen(requiredCondition.when, data); + + if (conditionMet && this.isEmpty(currentValue)) { + ctx.addIssue({ + path, + message: requiredCondition.message || `${field.name} is required`, + code: 'custom', + }); + } + } + + /* ----------------------------- + * CONDITIONAL VALIDATIONS + * ----------------------------- */ + for (const { field, path } of dynamicallyValidated) { + const validationCondition = field.validations.condition as any; + + const dependentValue = this.getValueByPath( + data, + validationCondition.field, + ); + const currentValue = this.getValueByPath(data, path.join('.')); + + // Handle then/else validations + if (field.validations?.condition) { + const cond = field.validations.condition; + const opResult = this.evaluateCondition( + dependentValue, + cond.operator, + cond.value, + ); + + const validationsToApply = opResult ? cond.then : cond.else; + if (validationsToApply) { + this.runConditionalRules( + validationsToApply, + field.type, + currentValue, + ctx, + path, + ); + } + } + } + }); + } + + private isOptionalField(field: FormField): boolean { + return !field.required || typeof field.required === 'object'; + } + + private isEmpty(value: any): boolean { + return value === undefined || value === null || value === ''; + } + + private getValueByPath(obj: any, path: string): any { + return path.split('.').reduce((acc, key) => acc?.[key], obj); + } + + private evaluateRule( + rule: ConditionalRule, + data: Record, + ): boolean { + const value = this.getValueByPath(data, rule.field); + + switch (rule.operator) { + case 'exists': + return value !== undefined; + + case 'missing': + return value === undefined; + + case 'null': + return value === null; + + case 'empty': + return this.isEmpty(value); + + case 'notEmpty': + return !this.isEmpty(value); + + case 'equals': + return value === rule.value; + + case 'notEquals': + return value !== rule.value; + + case 'in': + return Array.isArray(rule.value) && rule.value.includes(value); + + case 'notIn': + return !Array.isArray(rule.value) || !rule.value.includes(value); + + default: + return false; + } + } + + private evaluateWhen( + when: ConditionalWhen, + data: Record, + ): boolean { + if (when.all && Array.isArray(when.all)) { + return when.all.every((rule) => this.evaluateRule(rule, data)); + } + + if (when.any && Array.isArray(when.any)) { + return when.any.some((rule) => this.evaluateRule(rule, data)); + } + + return false; + } + + private collectConditionalFields( + fields: FormField[], + path: string[] = [], + ): { + dynamicallyRequired: { field: FormField; path: string[] }[]; + dynamicallyValidated: { field: FormField; path: string[] }[]; + } { + const dynamicallyRequired: { field: FormField; path: string[] }[] = []; + const dynamicallyValidated: { field: FormField; path: string[] }[] = []; + + for (const field of fields) { + const currentPath = [...path, field.name]; + + if (typeof field.required === 'object') { + dynamicallyRequired.push({ field, path: currentPath }); + } + + if (field.validations?.condition) { + dynamicallyValidated.push({ field, path: currentPath }); + } + + if (field.type === 'object' && field.fields) { + const nested = this.collectConditionalFields(field.fields, currentPath); + dynamicallyRequired.push(...nested.dynamicallyRequired); + dynamicallyValidated.push(...nested.dynamicallyValidated); + } + } + + return { dynamicallyRequired, dynamicallyValidated }; + } + + private shouldValidateConditional( + data: any, + path: (string | number)[], + ): boolean { + if (path.length <= 1) return true; + + const parentPath = path.slice(0, -1).join('.'); + const parentValue = this.getValueByPath(data, parentPath); + + return parentValue !== undefined && parentValue !== null; + } + + private evaluateCondition( + value: any, + operator: string, + compareValue: any, + ): boolean { + switch (operator) { + case 'in': + return (compareValue as any[]).includes(value); + case 'notIn': + return !(compareValue as any[]).includes(value); + case 'equals': + return value === compareValue; + case 'notEquals': + return value !== compareValue; + default: + return false; + } + } + + private runConditionalRules( + validations: FieldValidation | undefined, + type: string, + value: any, + ctx: z.RefinementCtx, + path: (string | number)[], + ) { + if (!validations || this.isEmpty(value)) return; + + // Regex + if (validations.regex) { + try { + const regex = new RegExp(validations.regex); + if (!regex.test(value)) { + ctx.addIssue({ + path, + code: 'custom', + message: validations.message || 'Invalid format', + }); + } + } catch { + throw new BadRequestException( + `Invalid regex pattern: ${validations.regex}`, + ); + } + } + + // String min/max + if (type === 'string') { + if (validations.min !== undefined && value?.length < validations.min) { + ctx.addIssue({ + path, + code: 'custom', + message: + validations.message || `Minimum length is ${validations.min}`, + }); + } + + if (validations.max !== undefined && value?.length > validations.max) { + ctx.addIssue({ + path, + code: 'custom', + message: + validations.message || `Maximum length is ${validations.max}`, + }); + } + } + + // Number min/max + if (type === 'number') { + if (validations.min !== undefined && value < validations.min) { + ctx.addIssue({ + path, + code: 'custom', + message: validations.message || `Minimum value is ${validations.min}`, + }); + } + + if (validations.max !== undefined && value > validations.max) { + ctx.addIssue({ + path, + code: 'custom', + message: validations.message || `Maximum value is ${validations.max}`, + }); + } + } } private buildFieldSchema(field: FormField): any { @@ -46,7 +307,7 @@ export class SchemaBuilderService { schema = z.array(itemSchema); // Handle required/optional for arrays - if (!field.required) { + if (this.isOptionalField(field)) { schema = schema.optional(); } return schema; @@ -61,7 +322,7 @@ export class SchemaBuilderService { schema = z.object(nestedShape); // Handle required/optional for objects - if (!field.required) { + if (this.isOptionalField(field)) { schema = schema.optional(); } return schema; @@ -72,7 +333,7 @@ export class SchemaBuilderService { case '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) { + if (!this.isOptionalField(field)) { schema = z.string().min(1, 'This field is required'); } else { schema = z.coerce.string(); @@ -89,7 +350,7 @@ export class SchemaBuilderService { break; case 'date': // For optional date fields, allow empty strings or valid dates - if (!field.required) { + if (this.isOptionalField(field)) { schema = z .string() .refine( @@ -119,12 +380,12 @@ export class SchemaBuilderService { schema, field.validations, field.type, - field.required, + field.required === true, // Only true means strictly required keep optional for objects ); } // Handle required/optional - if (!field.required) { + if (this.isOptionalField(field)) { schema = schema.optional(); } From fedf2535ddcfba7816da8297686741afffe6c8be Mon Sep 17 00:00:00 2001 From: Aaron Harris Date: Fri, 30 Jan 2026 11:44:18 -0400 Subject: [PATCH 3/7] chore: add testimonial fields to sell goods/services schema --- schemas/sell-goods-services-beach-park.json | 161 ++++++++++++++++++++ 1 file changed, 161 insertions(+) diff --git a/schemas/sell-goods-services-beach-park.json b/schemas/sell-goods-services-beach-park.json index 9b1e57d..8ae9b23 100644 --- a/schemas/sell-goods-services-beach-park.json +++ b/schemas/sell-goods-services-beach-park.json @@ -184,6 +184,7 @@ "value": "imported" }, "validations": { + "min": 2, "max": 100, "message": "Location must be at least 2 characters" } @@ -429,6 +430,166 @@ } ] }, + { + "name": "firstTestimonial", + "type": "object", + "required": true, + "fields": [ + { + "name": "firstName", + "type": "string", + "label": "First name", + "required": true, + "validations": { + "min": 2, + "max": 100, + "message": "First name must be at least 2 characters" + } + }, + { + "name": "lastName", + "type": "string", + "label": "Last name", + "required": true, + "validations": { + "min": 2, + "max": 100, + "message": "Last name must be at least 2 characters" + } + }, + { + "name": "relationship", + "type": "string", + "label": "Relationship", + "required": true, + "validations": { + "regex": "^(community-leader|mentor|religious-leader|teacher|coach|neighbour|other)$", + "message": "Must select a valid relationship" + } + }, + { + "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": "testimonial", + "type": "string", + "label": "Testimonial", + "required": true, + "validations": { + "min": 10, + "max": 1000, + "message": "Testimonial must be at least 10 characters" + } + } + ] + }, + { + "name": "secondTestimonial", + "type": "object", + "required": true, + "fields": [ + { + "name": "firstName", + "type": "string", + "label": "First name", + "required": true, + "validations": { + "min": 2, + "max": 100, + "message": "First name must be at least 2 characters" + } + }, + { + "name": "lastName", + "type": "string", + "label": "Last name", + "required": true, + "validations": { + "min": 2, + "max": 100, + "message": "Last name must be at least 2 characters" + } + }, + { + "name": "relationship", + "type": "string", + "label": "Relationship", + "required": true, + "validations": { + "regex": "^(community-leader|mentor|religious-leader|teacher|coach|neighbour|other)$", + "message": "Must select a valid relationship" + } + }, + { + "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": "testimonial", + "type": "string", + "label": "Testimonial", + "required": true, + "validations": { + "min": 10, + "max": 1000, + "message": "Testimonial must be at least 10 characters" + } + } + ] + }, { "name": "documents", "type": "object", From 23d04fe60f933ee2a88b8d72bcd8e214613df54d Mon Sep 17 00:00:00 2001 From: Ihtisham Tanveer <84310367+ihtishamtanveer@users.noreply.github.com> Date: Fri, 30 Jan 2026 21:25:52 +0500 Subject: [PATCH 4/7] fix: adjust token expiration time from 1 hour to 10 minutes (#87) * fix: adjust token expiration time from 1 hour to 10 minutes * feat(opencrvs): integrate jwt-decode to dynamically set expiration time with a fallback of 10 minutes. * refactor: imporve the calculation to expiry time --- package-lock.json | 10 ++++++++++ package.json | 1 + src/opencrvs/opencrvs.service.ts | 19 ++++++++++++++++--- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8a5552f..de3a788 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.2", "handlebars": "^4.7.8", + "jwt-decode": "^4.0.0", "node-cache": "^5.1.2", "pg": "^8.16.3", "reflect-metadata": "^0.1.13", @@ -9051,6 +9052,15 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", diff --git a/package.json b/package.json index b152295..96692ec 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "class-validator": "^0.14.2", "handlebars": "^4.7.8", "node-cache": "^5.1.2", + "jwt-decode": "^4.0.0", "pg": "^8.16.3", "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", diff --git a/src/opencrvs/opencrvs.service.ts b/src/opencrvs/opencrvs.service.ts index 30ab362..c0d2050 100644 --- a/src/opencrvs/opencrvs.service.ts +++ b/src/opencrvs/opencrvs.service.ts @@ -1,5 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { jwtDecode } from 'jwt-decode'; import { v4 as uuidv4 } from 'uuid'; import { TokenResponse, @@ -13,6 +14,8 @@ import { } from './types'; import { OpenCRVSCacheService } from './opencrvs-cache.service'; +const TOKEN_EXPIRY_FALLBACK_SECONDS = 10 * 60; // 10 minutes + @Injectable() export class OpenCRVSService { private readonly logger = new Logger(OpenCRVSService.name); @@ -104,9 +107,19 @@ export class OpenCRVSService { throw new Error('OpenCRVS token response missing access_token'); } - // Cache the token with TTL (includes 5-minute buffer) - const expiresIn = data.expires_in ?? 3600; - this.cacheService.setAccessToken(data.access_token, expiresIn); + const payload = jwtDecode<{ exp?: number }>(data.access_token); + + const currentTimeSec = Math.floor(Date.now() / 1000); + + // Default fallback in case the token has no `exp` + let secondsUntilExpiry = TOKEN_EXPIRY_FALLBACK_SECONDS; + + // If the JWT has an `exp`, calculate how many seconds remain until it expires + if (payload.exp) { + secondsUntilExpiry = payload.exp - currentTimeSec; + } + + this.cacheService.setAccessToken(data.access_token, secondsUntilExpiry); this.logger.log('OpenCRVS access token obtained successfully'); return data.access_token; From 4f983e544f69ae001b82b923afd4eb807e8fb7c0 Mon Sep 17 00:00:00 2001 From: Akinola Raphael Date: Fri, 30 Jan 2026 17:42:23 +0100 Subject: [PATCH 5/7] chore: multiple email config --- src/opencrvs/opencrvs-cache.service.ts | 4 ++-- src/processors/implementations/email.processor.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/opencrvs/opencrvs-cache.service.ts b/src/opencrvs/opencrvs-cache.service.ts index df60dce..080893e 100644 --- a/src/opencrvs/opencrvs-cache.service.ts +++ b/src/opencrvs/opencrvs-cache.service.ts @@ -34,8 +34,8 @@ export class OpenCRVSCacheService { * Includes a 5-minute buffer before actual expiry */ setAccessToken(accessToken: string, expiresIn: number): void { - // Apply 5-minute buffer to TTL - const bufferSeconds = 5 * 60; + // Apply 2-minute buffer to TTL + const bufferSeconds = 2 * 60; const ttl = Math.max(expiresIn - bufferSeconds, 0); if (ttl <= 0) { diff --git a/src/processors/implementations/email.processor.ts b/src/processors/implementations/email.processor.ts index 3f7dcce..30a3df5 100644 --- a/src/processors/implementations/email.processor.ts +++ b/src/processors/implementations/email.processor.ts @@ -39,7 +39,7 @@ export class EmailProcessor implements IProcessor { } await this.emailService.sendEmail({ - to, + to: to.split(',').map((email: string) => email.trim()), from, subject, template, From 70ff732127d2cc79c84f5881e0bdb3fce3acff6d Mon Sep 17 00:00:00 2001 From: Akinola Raphael Date: Fri, 30 Jan 2026 20:14:17 +0100 Subject: [PATCH 6/7] chore: update sell-goods-services-beach-park-receipt --- ...sell-goods-services-beach-park-receipt.hbs | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src/email/templates/sell-goods-services-beach-park-receipt.hbs b/src/email/templates/sell-goods-services-beach-park-receipt.hbs index bdcdb7f..d23d58f 100644 --- a/src/email/templates/sell-goods-services-beach-park-receipt.hbs +++ b/src/email/templates/sell-goods-services-beach-park-receipt.hbs @@ -26,8 +26,9 @@
-

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

- +

Thank you for your application

+

Your information has been sent to the National Conservation Commission (NCC)

+

Your Submission ID: {{submissionId}}

Please save this ID for your records.

@@ -35,12 +36,25 @@

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.

+

You will meet with representatives from the NCC at the location where you would like to sell goods or services. They will assess suitability.

+

If the outcome is positive, you will need to visit the National Conservation Commission to collect:

+
    +
  • A letter of authorisation.
  • +
  • A licence book outlining the conditions of where and what you can sell.
  • +
  • Your vendor identification.
  • +
-

Need help?

-

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

+

Pay for your license

+

You can pay for your vendor documentation in cash or by card when you visit the NCC.

+

The prices include VAT.

+
    +
  • Licence: $117.50
  • +
  • A watersports licence costs $176.25
  • +
  • Licence book: $11.75
  • +
  • ID badge: $12.87
  • +
From fb02f5686b1009ca931bbf6c79fdab7ed629bcd7 Mon Sep 17 00:00:00 2001 From: IsaiahSama Date: Fri, 30 Jan 2026 15:42:55 -0400 Subject: [PATCH 7/7] Updated confirmation email for Personal Mail --- ...-office-redirection-individual-receipt.hbs | 79 ++++++++++--------- 1 file changed, 41 insertions(+), 38 deletions(-) diff --git a/src/email/templates/post-office-redirection-individual-receipt.hbs b/src/email/templates/post-office-redirection-individual-receipt.hbs index 6a4f008..ca54666 100644 --- a/src/email/templates/post-office-redirection-individual-receipt.hbs +++ b/src/email/templates/post-office-redirection-individual-receipt.hbs @@ -1,7 +1,7 @@ - - - - - -
-
- 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.

-
-
- + + + +
+
+ Government of Barbados +
+
+
+

Thank you for your request

+

Your information has been sent to the Barbados Postal Service

+
+

+ Reference number: {{ submissionId }} +

+
+
+

What happens next

+

+ Everyone who is 18 years old and over who wants to redirect their personal mail must visit any Post Office, in person, to: +

+
    +
  1. Sign the form
  2. +
  3. Verify their identity with their National ID Card
  4. +
+

The Post Office cashier will ask for the reference number above.

+
+
+ +