@carecard/validate 3.1.23 → 3.1.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.codex/AGENTS.md CHANGED
@@ -92,6 +92,7 @@ The `pkg-*` directories are reusable CareCard packages. Shared API response, err
92
92
 
93
93
  - Write or update tests before implementation whenever changing behavior.
94
94
  - Testing is mandatory before finalizing code changes. Do not stop after implementation if tests, `.junie`, or `.husky` checks remain unrun.
95
+ - Code coverage must never be lower than the previous commit. When coverage tooling exists, compare against the previous commit or recorded baseline before finalizing, add tests to maintain or improve coverage, and never reduce coverage thresholds to make checks pass.
95
96
  - Keep tests readable and domain-specific. Prefer explicit helper names over generic test utilities that hide important behavior.
96
97
  - Use existing test frameworks and layouts:
97
98
  - JavaScript `api-*`: usually Mocha, Supertest, `test/index.test.js`, and Docker-backed Postgres scripts.
package/.husky/pre-commit CHANGED
@@ -1,5 +1,3 @@
1
- npm run lint:fix
2
- npm run format
3
-
4
1
  # Run tests
5
- npm run test:All
2
+ npm run test
3
+ npm run test:types
package/index.d.ts CHANGED
@@ -13,18 +13,10 @@ export interface ValidateWhitelistPropertiesOptions {
13
13
  convertToSnakeCase?: boolean;
14
14
  /**
15
15
  * When true, the returned object is flattened so that every validated leaf
16
- * becomes a top-level key. No nested objects remain in the output. Applied
17
- * after snake_case conversion.
16
+ * becomes a top-level key, joined by `.` (e.g. `{ 'user.first_name': 'Jane' }`).
17
+ * No nested objects remain in the output. Applied after snake_case conversion.
18
18
  */
19
19
  flattenOutput?: boolean;
20
- /**
21
- * Controls flattened key naming when `flattenOutput` is true.
22
- * - `path` uses full dot-notation paths, e.g. `{ "user.email": "Jane" }`.
23
- * - `leaf` uses only leaf names, e.g. `{ email: "Jane" }`.
24
- *
25
- * Defaults to `path`.
26
- */
27
- flattenKeyStyle?: 'path' | 'leaf';
28
20
  }
29
21
 
30
22
  /**
@@ -46,11 +38,10 @@ export interface ValidateWhitelistPropertiesOptions {
46
38
  * validated elements (e.g. `{ name: ["First", "Other"] }` is validated like
47
39
  * `{ name: "First" }` and `{ name: "Other" }` individually).
48
40
  * - Optionally converts the resulting keys (including nested keys) to snake_case.
49
- * - Optionally flattens the result after snake_case conversion.
50
41
  *
51
42
  * @param inputObject The input object (e.g. `req.body` or `req.params`).
52
43
  * @param requiredProperties Leaf paths that must be present and valid. Dot-notation supported.
53
- * @param options Optional additional leaf paths plus output transformation flags.
44
+ * @param options Optional list of additional allowed leaf paths and case-conversion flag.
54
45
  */
55
46
  export function validateWhitelistProperties(
56
47
  inputObject: Record<string, any>,
@@ -58,6 +49,24 @@ export function validateWhitelistProperties(
58
49
  options?: ValidateWhitelistPropertiesOptions,
59
50
  ): Promise<Record<string, any>>;
60
51
 
52
+ export const DEFAULT_USER_ROLE_REQUEST_ROLE: 'student';
53
+ export const REQUIRE_SCOPE_WHEN_ROLE_OR_SCOPE_PRESENT: 'whenRoleOrScopePresent';
54
+
55
+ export interface ValidateNewUserRoleRequestOptions {
56
+ defaultRole?: 'student' | undefined;
57
+ requireScope?: boolean | typeof REQUIRE_SCOPE_WHEN_ROLE_OR_SCOPE_PRESENT;
58
+ }
59
+
60
+ /**
61
+ * Normalizes and validates a carecard.new_user_role_request payload.
62
+ * Only student, intern, and volunteer are accepted. When scope is required,
63
+ * both institution_id and campus_id must be provided.
64
+ */
65
+ export function validateNewUserRoleRequestObject(
66
+ roleRequest?: Record<string, any>,
67
+ options?: ValidateNewUserRoleRequestOptions,
68
+ ): Record<string, any>;
69
+
61
70
  /** Checks if the string is a valid image URL format. */
62
71
  export function isImageUrl(imageUrl: any): boolean;
63
72
  /** Checks if the value is an integer. */
@@ -112,6 +121,8 @@ export function isTextString(str: any): boolean;
112
121
  export function isInStringArray(StringArray: string[], inputString: any): boolean;
113
122
  /** Checks if the string is one of the supported user role request statuses. */
114
123
  export function isUserRoleRequestStatusString(inputString: any): boolean;
124
+ /** Checks if the string is a supported new user role request role. */
125
+ export function isUserRoleRequestRoleString(inputString: any): boolean;
115
126
  /** Checks if the string is a valid country code (e.g., +1). */
116
127
  export function isCountryCodeString(str: any): boolean;
117
128
  /** Checks if the string is a valid domain name. */
@@ -158,6 +169,7 @@ export const validate: {
158
169
  isTextString: typeof isTextString;
159
170
  isInStringArray: typeof isInStringArray;
160
171
  isUserRoleRequestStatusString: typeof isUserRoleRequestStatusString;
172
+ isUserRoleRequestRoleString: typeof isUserRoleRequestRoleString;
161
173
  isCountryCodeString: typeof isCountryCodeString;
162
174
  isValidDomainName: typeof isValidDomainName;
163
175
  isValidTimestampzString: typeof isValidTimestampzString;
package/index.js CHANGED
@@ -1,11 +1,13 @@
1
1
  const validate = require('./lib/validate');
2
2
  const validateProperties = require('./lib/validateProperties');
3
3
  const validateWhitelistProperties = require('./lib/validateWhitelistProperties');
4
+ const validateNewUserRoleRequest = require('./lib/validateNewUserRoleRequest');
4
5
 
5
6
  module.exports = {
6
7
  validate,
7
8
  validateProperties,
8
9
  validateWhitelistProperties,
10
+ ...validateNewUserRoleRequest,
9
11
  ...validate,
10
12
  ...validateProperties,
11
13
  };
package/lib/validate.js CHANGED
@@ -173,6 +173,11 @@ const isUserRoleRequestStatusString = inputString => {
173
173
  return isInStringArray(statuses, inputString);
174
174
  };
175
175
 
176
+ const isUserRoleRequestRoleString = inputString => {
177
+ const roles = ['student', 'intern', 'volunteer'];
178
+ return typeof inputString === 'string' && roles.includes(inputString.trim().toLowerCase());
179
+ };
180
+
176
181
  const isCountryCodeString = str => {
177
182
  if (typeof str !== 'string' || str.length === 0 || str.length > 4) return false;
178
183
 
@@ -243,6 +248,7 @@ module.exports = {
243
248
  isTextString,
244
249
  isInStringArray,
245
250
  isUserRoleRequestStatusString,
251
+ isUserRoleRequestRoleString,
246
252
  isCountryCodeString,
247
253
  isValidDomainName,
248
254
  isValidTimestampzString,
@@ -0,0 +1,79 @@
1
+ 'use strict';
2
+
3
+ const {
4
+ error: { throwBadInputError },
5
+ } = require('@carecard/common-util');
6
+ const { isUserRoleRequestRoleString } = require('./validate');
7
+
8
+ const DEFAULT_USER_ROLE_REQUEST_ROLE = 'student';
9
+ const REQUIRE_SCOPE_WHEN_ROLE_OR_SCOPE_PRESENT = 'whenRoleOrScopePresent';
10
+
11
+ function validateNewUserRoleRequestObject(roleRequest = {}, options = {}) {
12
+ const normalized = normalizeNewUserRoleRequestObject(roleRequest);
13
+ const defaultRole = Object.prototype.hasOwnProperty.call(options, 'defaultRole') ? options.defaultRole : DEFAULT_USER_ROLE_REQUEST_ROLE;
14
+ const requireScope = Object.prototype.hasOwnProperty.call(options, 'requireScope') ? options.requireScope : true;
15
+
16
+ if (normalized.role_name === undefined && defaultRole !== undefined) {
17
+ normalized.role_name = defaultRole;
18
+ }
19
+
20
+ if (normalized.role_name !== undefined && !isUserRoleRequestRoleString(normalized.role_name)) {
21
+ throwBadInputError({
22
+ userMessage: 'Invalid property: role.role',
23
+ details: { role: 'Role requests are limited to student, intern, or volunteer' },
24
+ });
25
+ }
26
+
27
+ if (shouldRequireScope(normalized, requireScope)) {
28
+ requireNewUserRoleRequestScope(normalized);
29
+ }
30
+
31
+ return normalized;
32
+ }
33
+
34
+ function normalizeNewUserRoleRequestObject(roleRequest) {
35
+ const normalized = { ...roleRequest };
36
+
37
+ assignAlias(normalized, 'role_name', 'roleName');
38
+ assignAlias(normalized, 'role_name', 'role');
39
+ assignAlias(normalized, 'institution_id', 'institutionId');
40
+ assignAlias(normalized, 'campus_id', 'campusId');
41
+ assignAlias(normalized, 'program_id', 'programId');
42
+
43
+ delete normalized.roleName;
44
+ delete normalized.role;
45
+ delete normalized.institutionId;
46
+ delete normalized.campusId;
47
+ delete normalized.programId;
48
+
49
+ return normalized;
50
+ }
51
+
52
+ function assignAlias(target, canonicalKey, aliasKey) {
53
+ if (target[canonicalKey] === undefined && target[aliasKey] !== undefined) {
54
+ target[canonicalKey] = target[aliasKey];
55
+ }
56
+ }
57
+
58
+ function shouldRequireScope(roleRequest, requireScope) {
59
+ if (requireScope === true) return true;
60
+ if (requireScope !== REQUIRE_SCOPE_WHEN_ROLE_OR_SCOPE_PRESENT) return false;
61
+
62
+ return roleRequest.role_name !== undefined || roleRequest.institution_id !== undefined || roleRequest.campus_id !== undefined;
63
+ }
64
+
65
+ function requireNewUserRoleRequestScope(roleRequest) {
66
+ if (!roleRequest.institution_id) {
67
+ throwBadInputError({ userMessage: 'Missing property: role.institutionId' });
68
+ }
69
+
70
+ if (!roleRequest.campus_id) {
71
+ throwBadInputError({ userMessage: 'Missing property: role.campusId' });
72
+ }
73
+ }
74
+
75
+ module.exports = {
76
+ DEFAULT_USER_ROLE_REQUEST_ROLE,
77
+ REQUIRE_SCOPE_WHEN_ROLE_OR_SCOPE_PRESENT,
78
+ validateNewUserRoleRequestObject,
79
+ };
@@ -22,6 +22,7 @@ const {
22
22
  isStreetString,
23
23
  isTextString,
24
24
  isUserRoleRequestStatusString,
25
+ isUserRoleRequestRoleString,
25
26
  } = require('./validate');
26
27
 
27
28
  function validateProperties(obj = {}) {
@@ -196,6 +197,12 @@ function validateProperties(obj = {}) {
196
197
  returnObj[key] = value;
197
198
  }
198
199
  break;
200
+ case 'user_role_request_role':
201
+ case 'userRoleRequestRole':
202
+ if (isUserRoleRequestRoleString(value)) {
203
+ returnObj[key] = value;
204
+ }
205
+ break;
199
206
  case 'period':
200
207
  if (isCharactersString(value)) {
201
208
  returnObj[key] = value;
@@ -16,8 +16,6 @@ const MAX_NESTING_DEPTH = 5;
16
16
  * adversarial inputs.
17
17
  */
18
18
  const MAX_KEYS_PER_CALL = 5000;
19
- const DEFAULT_FLATTEN_KEY_STYLE = 'path';
20
- const VALID_FLATTEN_KEY_STYLES = new Set(['path', 'leaf']);
21
19
 
22
20
  /**
23
21
  * Returns true if the segment contains a mix of snake_case (underscore) and
@@ -163,35 +161,6 @@ function flattenObject(obj, prefix = '', out = {}) {
163
161
  return out;
164
162
  }
165
163
 
166
- /**
167
- * Recursively flattens a nested plain object using only each leaf property
168
- * name as the output key.
169
- *
170
- * Example: `{ a: { b: { c: 1, d: 2 } } }` => `{ c: 1, d: 2 }`.
171
- * If duplicate leaf keys exist at different nesting levels, the higher-level
172
- * leaf wins. If duplicate leaf keys exist at the same depth, the first
173
- * traversal wins.
174
- *
175
- * @param {Object} obj
176
- * @param {Object} [out]
177
- * @param {Object} [depthByKey]
178
- * @param {number} [depth]
179
- * @returns {Object}
180
- */
181
- function flattenObjectByLeafKey(obj, out = {}, depthByKey = {}, depth = 1) {
182
- for (const [key, value] of Object.entries(obj)) {
183
- if (value !== null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)) {
184
- flattenObjectByLeafKey(value, out, depthByKey, depth + 1);
185
- } else {
186
- if (!Object.prototype.hasOwnProperty.call(out, key) || depth < depthByKey[key]) {
187
- out[key] = value;
188
- depthByKey[key] = depth;
189
- }
190
- }
191
- }
192
- return out;
193
- }
194
-
195
164
  /**
196
165
  * Validates and transforms whitelisted properties from an input object.
197
166
  *
@@ -212,11 +181,8 @@ function flattenObjectByLeafKey(obj, out = {}, depthByKey = {}, depth = 1) {
212
181
  * element passes validation, and the returned value is an array of the
213
182
  * validated elements (in the same order).
214
183
  * 5. Optionally converts all keys (including nested) to snake_case.
215
- * 6. Optionally flattens the result (`flattenOutput`). Flattened keys use
216
- * full dot paths by default (`flattenKeyStyle: 'path'`) or direct leaf
217
- * names when requested (`flattenKeyStyle: 'leaf'`). For duplicate leaf
218
- * keys in leaf mode, the shallower value wins; ties keep the first value
219
- * encountered. Applied after snake_case conversion.
184
+ * 6. Optionally flattens the result so every leaf is a top-level key,
185
+ * joined by `.` (`flattenOutput`). Applied after snake_case conversion.
220
186
  *
221
187
  * @param {Object} inputObject - The input object (e.g., req.body / req.params).
222
188
  * @param {Array<string>} [requiredProperties=[]] - Leaf paths that MUST be present and valid.
@@ -224,26 +190,17 @@ function flattenObjectByLeafKey(obj, out = {}, depthByKey = {}, depth = 1) {
224
190
  * @param {Array<string>} [options.optionalProperties=[]] - Leaf paths allowed but not required.
225
191
  * @param {boolean} [options.convertToSnakeCase=false] - Whether to convert keys to snake_case.
226
192
  * @param {boolean} [options.flattenOutput=false] - Whether to flatten the result so that
227
- * every leaf is a top-level key, with no nested objects in the output.
228
- * @param {'path'|'leaf'} [options.flattenKeyStyle='path'] - Flattened key naming strategy
229
- * when `flattenOutput` is true. `path` uses dot-joined paths; `leaf` uses leaf names.
193
+ * every leaf is a top-level key (joined by `.`), with no nested objects in the output.
230
194
  * @returns {Promise<Object>} Resolves with the validated (and possibly transformed) object.
231
195
  */
232
196
  function validateWhitelistProperties(
233
197
  inputObject,
234
198
  requiredProperties = [],
235
- options = { optionalProperties: [], convertToSnakeCase: false, flattenOutput: false, flattenKeyStyle: DEFAULT_FLATTEN_KEY_STYLE },
199
+ options = { optionalProperties: [], convertToSnakeCase: false, flattenOutput: false },
236
200
  ) {
237
201
  const optionalProperties = (options && options.optionalProperties) || [];
238
202
  const convertToSnakeCase = !!(options && options.convertToSnakeCase);
239
203
  const flattenOutput = !!(options && options.flattenOutput);
240
- const flattenKeyStyle = options && options.flattenKeyStyle !== undefined ? options.flattenKeyStyle : DEFAULT_FLATTEN_KEY_STYLE;
241
-
242
- if (!VALID_FLATTEN_KEY_STYLES.has(flattenKeyStyle)) {
243
- throwBadInputError({
244
- userMessage: `Invalid flattenKeyStyle: ${String(flattenKeyStyle)}. Expected "path" or "leaf"`,
245
- });
246
- }
247
204
 
248
205
  // Cap the total number of paths to validate per call.
249
206
  const totalKeys = (requiredProperties ? requiredProperties.length : 0) + optionalProperties.length;
@@ -314,9 +271,9 @@ function validateWhitelistProperties(
314
271
  validatedObject = keysToSnakeCase(validatedObject);
315
272
  }
316
273
 
317
- // 6. Optional flattening.
274
+ // 6. Optional flattening: produce a flat object with dot-joined keys.
318
275
  if (flattenOutput) {
319
- validatedObject = flattenKeyStyle === 'leaf' ? flattenObjectByLeafKey(validatedObject) : flattenObject(validatedObject);
276
+ validatedObject = flattenObject(validatedObject);
320
277
  }
321
278
 
322
279
  return Promise.resolve(validatedObject);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@carecard/validate",
3
- "version": "3.1.23",
3
+ "version": "3.1.24",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/CareCard-ca/pkg-validate.git"
@@ -12,7 +12,7 @@
12
12
  "test": "mocha --recursive",
13
13
  "test:types": "tsc --noEmit && echo \"\\n ✔ Type tests passed: tsc --noEmit reported 0 errors across index.d.ts and test/**/*.ts\\n\"",
14
14
  "test:coverage": "tsc --noEmit && export NODE_ENV=test && nyc mocha --recursive",
15
- "test:All": "npm run test && npm run test:types",
15
+ "test:All": "npm run test:coverage && npm run test:types",
16
16
  "format": "prettier --write .",
17
17
  "format:check": "prettier --check .",
18
18
  "prepare": "husky",
@@ -39,5 +39,17 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@carecard/common-util": "^3.1.13"
42
+ },
43
+ "nyc": {
44
+ "all": true,
45
+ "include": [
46
+ "index.js",
47
+ "lib/**/*.js"
48
+ ],
49
+ "check-coverage": true,
50
+ "branches": 100,
51
+ "functions": 100,
52
+ "lines": 100,
53
+ "statements": 100
42
54
  }
43
55
  }