Skip to content

Commit 294b9c5

Browse files
[OCRVS-11194] Validate date before ref date (#11313)
* feat: add support for validating n days before/after a field reference date * chore: update CHANGELOG.md * fix: typo
1 parent b468b53 commit 294b9c5

File tree

4 files changed

+229
-13
lines changed

4 files changed

+229
-13
lines changed

CHANGELOG.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,26 @@
11
# Changelog
22

3-
## 2.0.0 Release Candidate
3+
## 1.9.3
4+
5+
### Improvements
6+
7+
- Add support for validating dates before/after another date field using `isBefore` and `isAfter` validators. [#11194](https://github.com/opencrvs/opencrvs-core/issues/11194)
8+
9+
Usage example:
10+
11+
```ts
12+
// 6570 days before another field
13+
field('mother.dob').isBefore().days(6570).fromDate(field('child.dob'))
14+
15+
// 6570 days after another field
16+
field('mother.dateOfMarriage')
17+
.isAfter()
18+
.days(6570)
19+
.fromDate(field('mother.dob'))
20+
21+
// 45 days before now
22+
field('child.dob').isBefore().days(45).fromNow()
23+
```
424

525
### New features
626

packages/commons/src/conditionals/conditionals.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,32 @@ describe('date comparisons', () => {
308308
})
309309
).toBe(false)
310310
})
311+
312+
it('validates comparisons to where date is expected to be certain days before certain date reference', () => {
313+
expect(
314+
validate(
315+
field('mother.dob').isBefore().days(6570).fromDate(field('child.dob')),
316+
{
317+
...getFieldParams({
318+
'child.dob': '1990-01-01',
319+
'mother.dob': '1969-12-31' // needs to be before 1972-12-31 ✅
320+
})
321+
}
322+
)
323+
).toBe(true)
324+
325+
expect(
326+
validate(
327+
field('mother.dob').isBefore().days(6570).fromDate(field('child.dob')),
328+
{
329+
...getFieldParams({
330+
'child.dob': '1990-01-01',
331+
'mother.dob': '1973-01-01' // needs to be before 1972-12-31 ❌
332+
})
333+
}
334+
)
335+
).toBe(false)
336+
})
311337
})
312338

313339
describe('age asDob comparisons', () => {

packages/commons/src/conditionals/conditionals.ts

Lines changed: 104 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,65 @@ function getDateRangeToFieldReference(
289289
}
290290
}
291291

292+
/**
293+
* This function will output JSONSchema which looks for example like this:
294+
* @example
295+
* {
296+
* "type": "object",
297+
* "properties": {
298+
* "mother.dob": {
299+
* "type": "string",
300+
* "format": "date",
301+
* "daysFromDate": {
302+
* "clause": "before",
303+
* "days": 30,
304+
* "referenceDate": {
305+
* "$data": "1/child.dob"
306+
* }
307+
* }
308+
* },
309+
* "child.dob": {
310+
* "type": "string",
311+
* "format": "date"
312+
* }
313+
* },
314+
* "required": ["mother.dob"]
315+
* }
316+
*/
317+
function getDayRangeToFieldReference(
318+
field: FieldReference,
319+
comparedField: FieldReference,
320+
days: number,
321+
clause: 'before' | 'after'
322+
) {
323+
return {
324+
type: 'object',
325+
properties: {
326+
[field.$$field]: wrapToPath(
327+
{
328+
type: 'string',
329+
format: 'date',
330+
daysFromDate: {
331+
referenceDate: {
332+
$data: `${field.$$subfield.length + 1}/${jsonFieldPath(
333+
comparedField
334+
)}`
335+
},
336+
clause,
337+
days
338+
}
339+
},
340+
field.$$subfield
341+
),
342+
[comparedField.$$field]: wrapToPath(
343+
{ type: 'string', format: 'date' },
344+
comparedField.$$subfield
345+
)
346+
},
347+
required: [field.$$field]
348+
}
349+
}
350+
292351
function defineComparison(
293352
field: FieldReference,
294353
value: number | FieldReference,
@@ -341,17 +400,19 @@ export function createFieldConditionals(fieldId: string) {
341400
const getDayRange = (
342401
field: FieldReference,
343402
days: number,
344-
clause: 'before' | 'after'
403+
clause: 'before' | 'after',
404+
referenceDate?: string
345405
) => ({
346406
type: 'object',
347407
properties: {
348408
[field.$$field]: wrapToPath(
349409
{
350410
type: 'string',
351411
format: 'date',
352-
daysFromNow: {
412+
daysFromDate: {
353413
days,
354-
clause
414+
clause,
415+
referenceDate
355416
}
356417
},
357418
field.$$subfield
@@ -412,7 +473,22 @@ export function createFieldConditionals(fieldId: string) {
412473
inPast: () =>
413474
defineFormConditional(getDayRange(this, -days, 'after')),
414475
inFuture: () =>
415-
defineFormConditional(getDayRange(this, days, 'after'))
476+
defineFormConditional(getDayRange(this, days, 'after')),
477+
fromDate: (
478+
date: `${string}-${string}-${string}` | FieldReference
479+
) => {
480+
if (isFieldReference(date)) {
481+
const comparedField = date
482+
return defineFormConditional(
483+
getDayRangeToFieldReference(this, comparedField, days, 'after')
484+
)
485+
}
486+
487+
return defineFormConditional(getDayRange(this, days, 'after', date))
488+
},
489+
fromNow: () => {
490+
return defineFormConditional(getDayRange(this, days, 'after'))
491+
}
416492
}),
417493
date: (date: string | FieldReference) => {
418494
if (isFieldReference(date)) {
@@ -438,7 +514,30 @@ export function createFieldConditionals(fieldId: string) {
438514
inPast: () =>
439515
defineFormConditional(getDayRange(this, -days, 'before')),
440516
inFuture: () =>
441-
defineFormConditional(getDayRange(this, days, 'before'))
517+
defineFormConditional(getDayRange(this, days, 'before')),
518+
fromDate: (
519+
date: `${string}-${string}-${string}` | FieldReference
520+
) => {
521+
if (isFieldReference(date)) {
522+
const comparedField = date
523+
524+
return defineFormConditional(
525+
getDayRangeToFieldReference(
526+
this,
527+
comparedField,
528+
-days,
529+
'before'
530+
)
531+
)
532+
}
533+
534+
return defineFormConditional(
535+
getDayRange(this, -days, 'before', date)
536+
)
537+
},
538+
fromNow: () => {
539+
return defineFormConditional(getDayRange(this, -days, 'before'))
540+
}
442541
}),
443542
date: (date: `${string}-${string}-${string}` | FieldReference) => {
444543
if (isFieldReference(date)) {

packages/commons/src/conditionals/validate.ts

Lines changed: 78 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,44 @@ const DataContext = z.object({
4646

4747
type DataContext = z.infer<typeof DataContext>
4848

49+
function resolveDataPath(
50+
rootData: Record<string, unknown>,
51+
dataPath: string,
52+
instancePath: string
53+
): unknown {
54+
const pathParts = dataPath.split('/')
55+
56+
// First part is the depth level (how many levels up to traverse)
57+
const levels = parseInt(pathParts[0], 10)
58+
const referencePath = pathParts.slice(1)
59+
60+
// Parse instancePath to get current location
61+
// instancePath looks like: "/$form/mother.dob"
62+
const instanceParts = instancePath.split('/').filter(Boolean) // Remove empty strings
63+
64+
// Traverse up by `levels` from current location
65+
const traversedParts = instanceParts.slice(0, -levels)
66+
67+
// Start from rootData and navigate to the traversed location
68+
let current: unknown | Record<string, unknown> = rootData
69+
for (const part of traversedParts) {
70+
if (current === null || current === undefined) {
71+
return undefined
72+
}
73+
current = (current as Record<string, unknown>)[part]
74+
}
75+
76+
// Now navigate using the reference path from that location
77+
for (const part of referencePath) {
78+
if (current === null || current === undefined) {
79+
return undefined
80+
}
81+
current = (current as Record<string, unknown>)[part]
82+
}
83+
84+
return current
85+
}
86+
4987
// https://ajv.js.org/packages/ajv-formats.html
5088
addFormats(ajv)
5189

@@ -60,24 +98,29 @@ addFormats(ajv)
6098
* "properties": {
6199
* "birthDate": {
62100
* "type": "string",
63-
* "daysFromNow": {
101+
* "daysFromDate": {
64102
* "days": 30,
65-
* "clause": "before"
103+
* "clause": "before",
104+
* "referenceDate": { "$data": "1/child.dob" } // optional, defaults to $now
66105
* }
67106
* }
68107
* }
69108
*/
70109
ajv.addKeyword({
71-
keyword: 'daysFromNow',
110+
keyword: 'daysFromDate',
72111
type: 'string',
73112
schemaType: 'object',
74113
$data: true,
75114
errors: true,
76115
validate(
77-
schema: { days: number; clause: 'after' | 'before' },
116+
schema: {
117+
days: number
118+
clause: 'after' | 'before'
119+
referenceDate?: string | { $data: string }
120+
},
78121
data: string,
79122
_: unknown,
80-
dataContext?: { rootData: unknown }
123+
dataContext?: { rootData: unknown; instancePath: string }
81124
) {
82125
if (
83126
!(
@@ -101,8 +144,35 @@ ajv.addKeyword({
101144
return false
102145
}
103146

104-
const now = new Date(dataContext.rootData.$now)
105-
const offsetDate = new Date(now.getTime() + days * 24 * 60 * 60 * 1000)
147+
let referenceDate = schema.referenceDate
148+
149+
if (
150+
referenceDate &&
151+
typeof referenceDate === 'object' &&
152+
'$data' in referenceDate
153+
) {
154+
/**
155+
* For some reason AJV does not resolve $data references automatically inside custom keywords
156+
* So we have to do it manually here
157+
*/
158+
referenceDate = resolveDataPath(
159+
dataContext.rootData,
160+
referenceDate.$data,
161+
dataContext.instancePath
162+
) as string
163+
}
164+
165+
if (!referenceDate) {
166+
referenceDate = dataContext.rootData.$now
167+
}
168+
169+
const baseDate = new Date(referenceDate)
170+
171+
if (isNaN(baseDate.getTime())) {
172+
return false
173+
}
174+
175+
const offsetDate = new Date(baseDate.getTime() + days * 24 * 60 * 60 * 1000)
106176

107177
return clause === 'after'
108178
? isAfter(date, offsetDate)
@@ -152,6 +222,7 @@ export function validate(schema: JSONSchema, data: ConditionalParameters) {
152222
})
153223
)
154224
}
225+
155226
const result = validator(data) as boolean
156227
return result
157228
}

0 commit comments

Comments
 (0)