Skip to content

feat(#10036): add PersonQualifier and related functions #10043

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Conversation

apoorvapendse
Copy link
Contributor

Description

WIP to integrate PersonQualifier to eventually add support for creating person documents.

Related to #10036.

Code review checklist

  • UI/UX backwards compatible: Test it works for the new design (enabled by default). And test it works in the old design, enable can_view_old_navigation permission to see the old design. Test it has appropriate design for RTL languages.
  • Readable: Concise, well named, follows the style guide, documented if necessary.
  • Documented: Configuration and user documentation on cht-docs
  • Tested: Unit and/or e2e where appropriate
  • Internationalised: All user facing text
  • Backwards compatible: Works with existing data and configuration or includes a migration. Any breaking changes documented in the release notes.

License

The software is provided under AGPL-3.0. Contributions to this project are accepted under the same license.

@sugat009 sugat009 self-requested a review June 11, 2025 13:39

if (data.type === 'contact' && !hasField(data, { name: 'contact_type', type: 'string' })) {
return false;
} else if (!(data.type === 'person')) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
} else if (!(data.type === 'person')) {
} else if (data.type !== 'person') {

Comment on lines 297 to 309
if (!isRecord(data)) {
throw new InvalidArgumentError('Invalid "data": expected an object.');
}
const qualifier = { ...data };
if ('reported_date' in qualifier && !isValidReportedDate(qualifier.reported_date)) {
throw new InvalidArgumentError(
// eslint-disable-next-line max-len
`Invalid reported_date. Expected format to be 'YYYY-MM-DDTHH:mm:ssZ', 'YYYY-MM-DDTHH:mm:ss.SSSZ', or a Unix epoch.`
);
}
if (!isContactQualifier(qualifier) || !hasField(data, { name: 'parent', type: 'object' })) {
throw new InvalidArgumentError(`Missing or empty required fields [${JSON.stringify(data)}].`);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: can the byContactQualifier function be reused here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same problem mentioned here.

Comment on lines 354 to 356
if (!isContactQualifier(data)) {
return false;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: this should be at the top of the file because the first two checks are included in that function

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That causes TypeScript to think that data is a ContactQualifer, which results in a lot of assertions for further checks.
Is there some way to deal with that?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, there a few ways to deal with this.

  1. reset type
export const isPersonQualifier = (data: unknown): data is PersonQualifier => {
  if (!isContactQualifier(data)) {
    return false;
  }
  
  // Reset data back to unknown for further type checking
  const unknownData = data as unknown;
  
  // Continue with your additional checks on unknownData
  // ...
};
  1. Double refactor the code. Put the common code from isContactQualifier and isPersonQualifier into another function.
const checkContactQualifierFields = (data: unknown): data is Record<string, unknown> => {
  return isRecord(data) && 
    hasFields(data, [
      {name: 'type', type: 'string', ensureTruthyValue: true}, 
      {name: 'name', type: 'string', ensureTruthyValue: true}
    ]) &&
    (!('reported_date' in data) || isValidReportedDate(data.reported_date));
};

export const isContactQualifier = (qualifier: unknown): qualifier is ContactQualifier => {
  return checkContactQualifierFields(qualifier);
};

export const isPersonQualifier = (data: unknown): data is PersonQualifier => {
  if (!checkContactQualifierFields(data)) {
    return false;
  }
  
  // data is still treated as Record<string, unknown> here, not ContactQualifier
  // Add your PersonQualifier-specific checks
  // ...
  
  return true;
};

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Try out the 2nd option as I think it will get the ball rolling for the other comments.

Comment on lines 314 to 318
if (data.type === 'contact' && !hasField(data, { name: 'contact_type', type: 'string' })) {
throw new InvalidArgumentError(`Missing or empty required fields [${JSON.stringify(data)}].`);
} else if (!(data.type === 'person')) {
throw new InvalidArgumentError('Expected `type` to be `person`.');
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: duplicate code with lines 358-362, refactor into a function

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same argument as the one I made here, we throw different errors for different stuff and booleans in case of the is functions.

I think we did something similar for the ContactQualifier, where we redundantly copied the checks to throw errors/false returns based on whether it was a is or by function.

Comment on lines 320 to 329
// Ensure parent lineage doesn't have any additional properties other than `_id` and `parent`.
let parent = data.parent;
while (parent) {
if (Object.keys(parent).length > 2) {
// This means that the parent certainly has extra fields and is not minfied/de-hydrated as per
// our liking as `isNormalized` check ensures that it does have two keys `_id` and `parent`.
throw new InvalidArgumentError(`Additional fields found in the parent lineage [${JSON.stringify(data)}].`);
}
parent = data.parent;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: duplicate code with lines 344-352, refactor into a function

Copy link
Contributor Author

@apoorvapendse apoorvapendse Jun 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason I didn't refactor into a function is because this check depends on first checking isNormalizedParent which is done in the previous steps of both the functions, so it cannot be used independently.
also including isNormalizedParent call in the new extracted function wouldn't be convenient because of the fact that it throws a different error and the same throwing error or returning false dilemma we have been facing for the qualifiers.

Comment on lines 344 to 352
let parent = data.parent;
while (parent) {
if (Object.keys(parent).length > 2) {
// This means that the parent certainly has extra fields and is not minfied/de-hydrated as per
// our liking as `isNormalized` check ensures that it does have two keys `_id` and `parent`.
return false;
}
parent = data.parent;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: this is more of a design decision. Personally, I think it's cleaner to have just a immediate parent as an field and not allow more nests. We could add the grandparents and so on from the server side. There's no benefit of the current way other than not having to query the database for the grandparents.

@jkuester What are you thoughts on this?

Copy link
Contributor Author

@apoorvapendse apoorvapendse Jun 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Cross posting from the call with hopefully better clarity)
I think going from the current version to the version @sugat009 mentioned would be easier for the user.
Transitioning from a stricter to more lenient data requirement would introduce complexity and we can make it broader after doing the "MVP" version.
The ideal version would probably be the one where someone can just pass the immediate parent_id or the entire lineage(normalized or hydrated) and we just ignore everything apart from the immediate parent to do our own lineage checks up the hierarchy.

Copy link
Contributor Author

@apoorvapendse apoorvapendse Jun 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current assumption with createPerson and PersonQualifier methods is that the user passed in the correct lineage. We do throw errors if it is bloated or misses the required _id, parent fields.
I'd like to keep it this way and maybe come back to building the more forgiving and robust version later down the line.
Would love to hear your thoughts @jkuester, @sugat009.

Comment on lines 168 to 169
byContactQualifierNonAssertive(data);
return data as ContactQualifier;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: shouldn't this be returning the function instead like this?

Suggested change
byContactQualifierNonAssertive(data);
return data as ContactQualifier;
return byContactQualifierNonAssertive(data) as ContactQualifier;

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah makes sense.

Comment on lines 342 to 346
if (!hasValidContactType(data) && !hasValidLegacyContactType(data, 'person')) {
return false;
}

return true;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (!hasValidContactType(data) && !hasValidLegacyContactType(data, 'person')) {
return false;
}
return true;
return hasValidContactType(data) || hasValidLegacyContactType(data, 'person');

equivalent?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yup

Comment on lines 366 to 369
if (data.type === 'contact' && hasField(data, { name: 'contact_type', type: 'string' })) {
return true;
}
return false;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (data.type === 'contact' && hasField(data, { name: 'contact_type', type: 'string' })) {
return true;
}
return false;
return data.type === 'contact' && hasField(data, { name: 'contact_type', type: 'string' })

@apoorvapendse apoorvapendse requested a review from sugat009 June 12, 2025 11:49
{name: 'type', type: 'string', ensureTruthyValue: true},
{name: 'name', type: 'string', ensureTruthyValue: true}
]) &&
(!('reported_date' in data) || isValidReportedDate(data.reported_date));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: if the reported_date does not exist, shouldn't it be added to it? Or are you planning on adding it elsewhere?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense, added in the latest commit.

Copy link
Member

@sugat009 sugat009 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Almost there. Some final touches.

Comment on lines 240 to 243
insertReportedDateIfMissing(data);

const qualifier = {...data};
if ('reported_date' in qualifier && !isValidReportedDate(qualifier.reported_date)) {
if (!isValidReportedDate(qualifier.reported_date)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: the addition of date should be done after line 242, because the whole point of deep copying the data object into qualifier is to not mutate the passed in data object

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, right.

.throw(`Additional fields found in the parent lineage [${JSON.stringify(data)}].`);
});

it('builds qualifier for valid objects', () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: can you add a test for when a reported_date already exists within the passed body

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, added in the last commit.

apoorvapendse added a commit to apoorvapendse/cht-core that referenced this pull request Jun 13, 2025
@sugat009 sugat009 merged commit c734c65 into medic:9835-add-cht-datasource-apis-for-creation-and-update-of-contacts-and-reports Jun 13, 2025
22 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants