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/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/schemas/sell-goods-services-beach-park.json b/schemas/sell-goods-services-beach-park.json index 95f52e2..8ae9b23 100644 --- a/schemas/sell-goods-services-beach-park.json +++ b/schemas/sell-goods-services-beach-park.json @@ -430,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", 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/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 +
+
+
+

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.

+
+
+ + 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
  • +
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-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/opencrvs/opencrvs.service.ts b/src/opencrvs/opencrvs.service.ts index f57dd96..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); @@ -23,30 +26,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'); + // Cache for access token with expiry + private accessToken: string | null = null; + private tokenExpiry: Date | null = null; - 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 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( @@ -108,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; 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/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, 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(); }