Skip to content

Commit ffe5fd6

Browse files
committed
feat: add interview timestamp audit checks and clarify timestamp units
Add three new audit checks to validate interview timestamps: - I_M_StartedAt: Validates that interview has a start timestamp - I_I_StartedAtBeforeSurveyStartDate: Flags interviews started before survey period - I_I_StartedAtAfterSurveyEndDate: Flags interviews started after survey period The new checks respect survey configuration boundaries and handle edge cases (undefined timestamps, missing paradata, unconfigured survey dates). Refactored timestamp handling to use explicit !== undefined checks instead of truthy checks, ensuring proper handling of edge cases like startedAt: 0. Added inline comments documenting the seconds-to-milliseconds conversion. Updated InterviewParadata type documentation to explicitly specify that startedAt, updatedAt, and completedAt are stored as Unix epoch timestamps in seconds (not milliseconds) for clarity across the codebase. Add two new functions to the DateTimeUtils module: secondsToMillisecondsTimestamp and parseISODateToTimestamp. These functions are used to convert seconds to milliseconds and parse ISO date strings to timestamps. Changes: - Add InterviewAuditChecks: I_M_StartedAt, I_I_StartedAtBeforeSurveyStartDate, I_I_StartedAtAfterSurveyEndDate - Add comprehensive test suites for all three audit checks (17 tests total) - Add English and French translations for audit error messages - Update InterviewParadata type definitions with clarified timestamp units - Improve timestamp conversion logic for better readability and correctness - Add two new functions to the DateTimeUtils module: secondsToMillisecondsTimestamp and parseISODateToTimestamp
1 parent 84ae15e commit ffe5fd6

File tree

10 files changed

+858
-5
lines changed

10 files changed

+858
-5
lines changed

locales/en/audits.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
{
22
"I_M_Languages": "Interview languages are missing",
3+
"I_M_StartedAt": "Interview start time is missing",
4+
"I_I_StartedAtBeforeSurveyStartDate": "Interview start time is before survey start date",
5+
"I_I_StartedAtAfterSurveyEndDate": "Interview start time is after survey end date",
36

47
"HH_I_Size": "Household size is out of range (should be between 1 and 20)",
58
"HH_M_Size": "Household size is missing",

locales/fr/audits.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
{
22
"I_M_Languages": "Les langues de l'entrevue sont manquantes",
3+
"I_M_StartedAt": "La date de début de l'entrevue est manquante",
4+
"I_I_StartedAtBeforeSurveyStartDate": "Le début de l'entrevue est avant la date de début de l'enquête",
5+
"I_I_StartedAtAfterSurveyEndDate": "Le début de l'entrevue est après la date de fin de l'enquête",
36

47
"HH_I_Size": "La taille du ménage est en dehors de la plage autorisée (devrait être entre 1 et 20)",
58
"HH_M_Size": "La taille du ménage est manquante",

packages/evolution-backend/src/services/audits/auditChecks/checks/InterviewAuditChecks.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
import type { AuditForObject } from 'evolution-common/lib/services/audits/types';
99
import type { InterviewAuditCheckContext, InterviewAuditCheckFunction } from '../AuditCheckContexts';
10+
import projectConfig from 'evolution-common/lib/config/project.config';
11+
import { secondsToMillisecondsTimestamp, parseISODateToTimestamp } from 'evolution-common/lib/utils/DateTimeUtils';
1012

1113
export const interviewAuditChecks: { [errorCode: string]: InterviewAuditCheckFunction } = {
1214
/**
@@ -29,5 +31,98 @@ export const interviewAuditChecks: { [errorCode: string]: InterviewAuditCheckFun
2931
};
3032
}
3133
return undefined;
34+
},
35+
36+
/**
37+
* Check if interview start date is missing or invalid
38+
* @param context - InterviewAuditCheckContext
39+
* @returns AuditForObject | undefined
40+
*/
41+
I_M_StartedAt: (context: InterviewAuditCheckContext): AuditForObject | undefined => {
42+
const { interview } = context;
43+
const startedAt = interview.paradata?.startedAt;
44+
// Consider startedAt missing/invalid if it's undefined, null, not finite (NaN/Infinity), or negative
45+
const hasValidStartDate =
46+
startedAt !== undefined && startedAt !== null && Number.isFinite(startedAt) && startedAt >= 0;
47+
if (!hasValidStartDate) {
48+
return {
49+
objectType: 'interview',
50+
objectUuid: interview.uuid!,
51+
errorCode: 'I_M_StartedAt',
52+
version: 1,
53+
level: 'error',
54+
message: 'Interview start time is missing',
55+
ignore: false
56+
};
57+
}
58+
return undefined;
59+
},
60+
61+
/**
62+
* Check if interview started at timestamp is before the survey start date
63+
* Will be ignored if survey start date is not set.
64+
* @param context - InterviewAuditCheckContext
65+
* @returns AuditForObject | undefined
66+
*/
67+
I_I_StartedAtBeforeSurveyStartDate: (context: InterviewAuditCheckContext): AuditForObject | undefined => {
68+
const { interview } = context;
69+
70+
// Convert startedAt from seconds to milliseconds, validating it's finite
71+
const interviewStartTimestamp = secondsToMillisecondsTimestamp(interview.paradata?.startedAt);
72+
73+
// Parse survey start date and guard against invalid date strings
74+
const surveyStartTimestamp = parseISODateToTimestamp(projectConfig.startDateTimeWithTimezoneOffset);
75+
76+
// Only perform comparison when both timestamps are valid and finite
77+
if (
78+
interviewStartTimestamp !== undefined &&
79+
surveyStartTimestamp !== undefined &&
80+
interviewStartTimestamp < surveyStartTimestamp
81+
) {
82+
return {
83+
objectType: 'interview',
84+
objectUuid: interview.uuid!,
85+
errorCode: 'I_I_StartedAtBeforeSurveyStartDate',
86+
version: 1,
87+
level: 'error',
88+
message: 'Interview start time is before survey start date',
89+
ignore: false
90+
};
91+
}
92+
return undefined;
93+
},
94+
95+
/**
96+
* Check if interview started at timestamp is after the survey end date
97+
* Will be ignored if survey end date is not set.
98+
* @param context - InterviewAuditCheckContext
99+
* @returns AuditForObject | undefined
100+
*/
101+
I_I_StartedAtAfterSurveyEndDate: (context: InterviewAuditCheckContext): AuditForObject | undefined => {
102+
const { interview } = context;
103+
104+
// Convert startedAt from seconds to milliseconds, validating it's finite
105+
const interviewStartTimestamp = secondsToMillisecondsTimestamp(interview.paradata?.startedAt);
106+
107+
// Parse survey end date and guard against invalid date strings
108+
const surveyEndTimestamp = parseISODateToTimestamp(projectConfig.endDateTimeWithTimezoneOffset);
109+
110+
// Only perform comparison when both timestamps are valid and finite
111+
if (
112+
interviewStartTimestamp !== undefined &&
113+
surveyEndTimestamp !== undefined &&
114+
interviewStartTimestamp > surveyEndTimestamp
115+
) {
116+
return {
117+
objectType: 'interview',
118+
objectUuid: interview.uuid!,
119+
errorCode: 'I_I_StartedAtAfterSurveyEndDate',
120+
version: 1,
121+
level: 'error',
122+
message: 'Interview start time is after survey end date',
123+
ignore: false
124+
};
125+
}
126+
return undefined;
32127
}
33128
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
/*
2+
* Copyright 2025, Polytechnique Montreal and contributors
3+
*
4+
* This file is licensed under the MIT License.
5+
* License text available at https://opensource.org/licenses/MIT
6+
*/
7+
8+
import { v4 as uuidV4 } from 'uuid';
9+
import type { InterviewAuditCheckContext } from '../../../AuditCheckContexts';
10+
import { InterviewParadata } from 'evolution-common/lib/services/baseObjects/interview/InterviewParadata';
11+
import { ISODateTimeStringWithTimezoneOffset } from 'evolution-common/lib/utils/DateTimeUtils';
12+
import { createMockInterview } from './testHelper';
13+
14+
// Note: These tests use jest.isolateModulesAsync to ensure projectConfig mutations don't leak between tests
15+
// running in parallel across different test files. We use await import() inside isolateModulesAsync to get
16+
// fresh module instances per test, ensuring each test has an isolated projectConfig state.
17+
describe('I_I_StartedAtAfterSurveyEndDate audit check', () => {
18+
const validUuid = uuidV4();
19+
20+
describe('should pass in valid scenarios', () => {
21+
it.each([
22+
{
23+
description: 'interview startedAt is before survey end date',
24+
endDate: '2025-12-31T23:59:59-05:00' as ISODateTimeStringWithTimezoneOffset,
25+
startedAt: new Date('2025-06-01T12:00:00-05:00').getTime() / 1000,
26+
hasParadata: true
27+
},
28+
{
29+
description: 'interview startedAt equals survey end date',
30+
endDate: '2025-12-31T23:59:59-05:00' as ISODateTimeStringWithTimezoneOffset,
31+
startedAt: new Date('2025-12-31T23:59:59-05:00').getTime() / 1000,
32+
hasParadata: true
33+
},
34+
{
35+
description: 'interview startedAt equals survey end date across different timezones',
36+
endDate: '2025-12-31T23:59:59-05:00' as ISODateTimeStringWithTimezoneOffset,
37+
startedAt: new Date('2026-01-01T04:59:59+00:00').getTime() / 1000, // Same instant as 2025-12-31T23:59:59-05:00
38+
hasParadata: true
39+
},
40+
{
41+
description: 'survey end date is not configured',
42+
endDate: undefined,
43+
startedAt: new Date('2026-12-31T23:59:59-05:00').getTime() / 1000,
44+
hasParadata: true
45+
},
46+
{
47+
description: 'interview startedAt is missing',
48+
endDate: '2025-12-31T23:59:59-05:00' as ISODateTimeStringWithTimezoneOffset,
49+
startedAt: undefined,
50+
hasParadata: true
51+
},
52+
{
53+
description: 'interview has no paradata',
54+
endDate: '2025-12-31T23:59:59-05:00' as ISODateTimeStringWithTimezoneOffset,
55+
startedAt: undefined,
56+
hasParadata: false
57+
},
58+
{
59+
description: 'survey end date is invalid (should be treated as not configured)',
60+
endDate: 'invalid-date-string' as ISODateTimeStringWithTimezoneOffset,
61+
startedAt: new Date('2026-12-31T23:59:59-05:00').getTime() / 1000,
62+
hasParadata: true
63+
},
64+
{
65+
description: 'survey end date is malformed ISO string',
66+
endDate: '2025-13-45T99:99:99-05:00' as ISODateTimeStringWithTimezoneOffset,
67+
startedAt: new Date('2025-06-01T12:00:00-05:00').getTime() / 1000,
68+
hasParadata: true
69+
},
70+
{
71+
description: 'interview startedAt is NaN (should be treated as missing)',
72+
endDate: '2025-12-31T23:59:59-05:00' as ISODateTimeStringWithTimezoneOffset,
73+
startedAt: NaN,
74+
hasParadata: true
75+
},
76+
{
77+
description: 'interview startedAt is Infinity (should be treated as missing)',
78+
endDate: '2025-12-31T23:59:59-05:00' as ISODateTimeStringWithTimezoneOffset,
79+
startedAt: Infinity,
80+
hasParadata: true
81+
},
82+
{
83+
description: 'interview startedAt is -Infinity (should be treated as missing)',
84+
endDate: '2025-12-31T23:59:59-05:00' as ISODateTimeStringWithTimezoneOffset,
85+
startedAt: -Infinity,
86+
hasParadata: true
87+
}
88+
])('$description', async ({ endDate, startedAt, hasParadata }) => {
89+
await jest.isolateModulesAsync(async () => {
90+
// Import modules inside isolateModules to get fresh instances
91+
const { default: projectConfig } = await import('evolution-common/lib/config/project.config');
92+
const { interviewAuditChecks } = await import('../../InterviewAuditChecks');
93+
94+
// Set config for this test
95+
projectConfig.endDateTimeWithTimezoneOffset = endDate;
96+
97+
const interview = createMockInterview();
98+
interview.paradata = hasParadata ? new InterviewParadata({ startedAt }) : undefined;
99+
const context: InterviewAuditCheckContext = { interview };
100+
101+
const result = interviewAuditChecks.I_I_StartedAtAfterSurveyEndDate(context);
102+
103+
expect(result).toBeUndefined();
104+
});
105+
});
106+
});
107+
108+
describe('should fail when interview startedAt is after survey end date', () => {
109+
it.each([
110+
{
111+
description: 'in same timezone',
112+
endDate: '2025-12-31T23:59:59-05:00' as ISODateTimeStringWithTimezoneOffset,
113+
startedAt: new Date('2026-01-01T00:00:00-05:00').getTime() / 1000
114+
},
115+
{
116+
description: 'across different timezones (UTC)',
117+
endDate: '2025-12-31T23:59:59-05:00' as ISODateTimeStringWithTimezoneOffset,
118+
startedAt: new Date('2026-01-01T05:00:00+00:00').getTime() / 1000 // 1 second after end date
119+
},
120+
{
121+
description: 'across different timezones (PST)',
122+
endDate: '2025-12-31T23:59:59-05:00' as ISODateTimeStringWithTimezoneOffset,
123+
startedAt: new Date('2025-12-31T21:00:00-08:00').getTime() / 1000 // Same as 2026-01-01T00:00:00-05:00
124+
},
125+
{
126+
description: 'across different timezones (JST)',
127+
endDate: '2025-12-31T23:59:59-05:00' as ISODateTimeStringWithTimezoneOffset,
128+
startedAt: new Date('2026-01-01T14:00:00+09:00').getTime() / 1000 // Same as 2026-01-01T00:00:00-05:00
129+
},
130+
{
131+
description: 'with positive timezone offset',
132+
endDate: '2025-12-31T23:59:59+02:00' as ISODateTimeStringWithTimezoneOffset,
133+
startedAt: new Date('2026-01-01T00:00:00+02:00').getTime() / 1000
134+
}
135+
])('$description', async ({ endDate, startedAt }) => {
136+
await jest.isolateModulesAsync(async () => {
137+
// Import modules inside isolateModules to get fresh instances
138+
const { default: projectConfig } = await import('evolution-common/lib/config/project.config');
139+
const { interviewAuditChecks } = await import('../../InterviewAuditChecks');
140+
141+
// Set config for this test
142+
projectConfig.endDateTimeWithTimezoneOffset = endDate;
143+
144+
const interview = createMockInterview(undefined, validUuid);
145+
interview.paradata = new InterviewParadata({ startedAt });
146+
const context: InterviewAuditCheckContext = { interview };
147+
148+
const result = interviewAuditChecks.I_I_StartedAtAfterSurveyEndDate(context);
149+
150+
expect(result).toEqual({
151+
objectType: 'interview',
152+
objectUuid: validUuid,
153+
errorCode: 'I_I_StartedAtAfterSurveyEndDate',
154+
version: 1,
155+
level: 'error',
156+
message: 'Interview start time is after survey end date',
157+
ignore: false
158+
});
159+
});
160+
});
161+
});
162+
163+
describe('should pass with edge cases across timezones', () => {
164+
it.each([
165+
{
166+
description: 'UTC to EST - same instant',
167+
endDate: '2025-12-31T23:59:59-05:00' as ISODateTimeStringWithTimezoneOffset,
168+
startedAt: new Date('2026-01-01T04:59:59+00:00').getTime() / 1000 // Same as end date
169+
},
170+
{
171+
description: 'PST to EST - same instant',
172+
endDate: '2025-12-31T23:59:59-05:00' as ISODateTimeStringWithTimezoneOffset,
173+
startedAt: new Date('2025-12-31T20:59:59-08:00').getTime() / 1000 // Same as end date
174+
},
175+
{
176+
description: 'JST to EST - before end date',
177+
endDate: '2025-12-31T23:59:59-05:00' as ISODateTimeStringWithTimezoneOffset,
178+
startedAt: new Date('2026-01-01T13:00:00+09:00').getTime() / 1000 // 23:00:00-05:00 (before)
179+
},
180+
{
181+
description: 'positive timezone offset - before end date',
182+
endDate: '2025-12-31T23:59:59+02:00' as ISODateTimeStringWithTimezoneOffset,
183+
startedAt: new Date('2025-12-31T20:00:00+02:00').getTime() / 1000
184+
}
185+
])('$description', async ({ endDate, startedAt }) => {
186+
await jest.isolateModulesAsync(async () => {
187+
// Import modules inside isolateModules to get fresh instances
188+
const { default: projectConfig } = await import('evolution-common/lib/config/project.config');
189+
const { interviewAuditChecks } = await import('../../InterviewAuditChecks');
190+
191+
// Set config for this test
192+
projectConfig.endDateTimeWithTimezoneOffset = endDate;
193+
194+
const interview = createMockInterview();
195+
interview.paradata = new InterviewParadata({ startedAt });
196+
const context: InterviewAuditCheckContext = { interview };
197+
198+
const result = interviewAuditChecks.I_I_StartedAtAfterSurveyEndDate(context);
199+
200+
expect(result).toBeUndefined();
201+
});
202+
});
203+
});
204+
});

0 commit comments

Comments
 (0)