Skip to content

Determine if license is eligible/ineligible in the public search list view response#1558

Open
landonshumway-ia wants to merge 31 commits into
csg-org:mainfrom
InspiringApps:feat/cosm-license-eligibility
Open

Determine if license is eligible/ineligible in the public search list view response#1558
landonshumway-ia wants to merge 31 commits into
csg-org:mainfrom
InspiringApps:feat/cosm-license-eligibility

Conversation

@landonshumway-ia
Copy link
Copy Markdown
Collaborator

@landonshumway-ia landonshumway-ia commented May 11, 2026

For the cosmetology public license list view, the frontend will be adding a column that shows if the license for that row is eligible or ineligible. This updates the API for the respective search endpoint to return a new field 'licenseEligibility', which the frontend will use to populate that column for the list view.

Testing List

  • yarn test:unit:all should run without errors or warnings
  • yarn serve should run without errors or warnings
  • yarn build should run without errors or warnings
  • For API configuration changes: CDK tests added/updated in backend/compact-connect/tests/unit/test_api.py
  • For API endpoint changes: OpenAPI spec updated to show latest endpoint configuration run compact-connect/bin/download_oas30.py
  • Code review

Closes #1479

Summary by CodeRabbit

  • New Features

    • Home‑jurisdiction change notifications for practitioners.
    • Public license search now reports per‑license eligibility (eligible/ineligible).
  • Bug Fixes

    • Improved license eligibility determination (expiration, adverse‑action and jurisdiction rules).
    • Generate and handle home‑state change events when a provider’s home jurisdiction updates.
  • Tests

    • Added unit, functional and smoke tests for notifications, event generation, and eligibility scenarios.
  • Chores

    • Updated third‑party dependency versions.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 11, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Detect home-jurisdiction changes on license ingest, emit provider.homeStateChange events, route via EventBridge→SQS, and notify the former jurisdiction’s operations team by email; update provider indexing, public-search eligibility, infra wiring, tests, and API docs.

Changes

Home Jurisdiction Change Notification

Layer / File(s) Summary
Dependency updates & manifests
backend/*/requirements*.txt, backend/*/package.json
Bumped Node deps (fast-xml-parser, AWS SDK v3 clients) and updated many Python urllib3 pins across requirements files.
Node.js Email service & Lambda wiring
backend/cosmetology-app/lambdas/nodejs/lib/email/*, backend/cosmetology-app/lambdas/nodejs/email-notification-service/lambda.ts, backend/cosmetology-app/lambdas/nodejs/tests/*
Added EmailNotificationService, removed unused BaseEmailService layout helpers, exported and wired homeJurisdictionChangeNotification template, and added Jest tests.
Event schema & clients (Python)
backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/data_event/api.py, .../event_bus_client.py, .../email_service_client.py
Added HomeJurisdictionChangeEventDetailSchema, EventBusClient.generate_home_jurisdiction_change_event, and EmailServiceClient.send_provider_home_state_change_email plus a template-variables dataclass.
Provider record & license selection
backend/cosmetology-app/lambdas/python/common/cc_common/data_model/provider_record_util.py, .../schema/license/api.py, .../search/opensearch_client.py
Replaced license selection with most-recent-by-date APIs, precompute provider-level adverseActions, added mostRecentLicenseForType, set expired license compactEligibility to ineligible, and updated OpenSearch mapping.
Provider-data ingest detection
backend/cosmetology-app/lambdas/python/provider-data-v1/handlers/ingest.py
When an uploaded license becomes the most-recent and its jurisdiction differs from provider top-level licenseJurisdiction, generate provider.homeStateChange event and persist updated provider record.
Data events handler
backend/cosmetology-app/lambdas/python/data-events/handlers/home_state_change_events.py
Added home_state_change_notification_listener SQS handler that loads event detail, fetches provider top-level record, and sends notification email to the former jurisdiction.
Investigation & encumbrance handlers
backend/cosmetology-app/lambdas/python/data-events/handlers/investigation_events.py, .../encumbrance_events.py
Refactored notification listeners to call get_provider_top_level_record(...) and removed the _get_provider_records helper.
OpenSearch & public search
backend/cosmetology-app/lambdas/python/search/*
Added adverseActions nested mapping and mostRecentLicenseForType mapping; public search now derives licenseEligibility from the full provider document (unlifted adverse actions, expiration, jurisdiction-uploaded eligibility, validation) and restricts nested matches to mostRecentLicenseForType.
Notification stack infra
backend/cosmetology-app/stacks/notification_stack.py, backend/cosmetology-app/tests/app/*
Added _add_provider_home_state_change_notification_listener to register Lambda/SQS/DLQ/EventBridge rule and included tests asserting resource creation and wiring.
Tests & smoke helpers
backend/cosmetology-app/tests/*, backend/compact-connect/tests/*
Added unit/function/Jest tests for new clients/handlers/ingest/public-search; added smoke tests and smoke helpers for multi-jurisdiction flows and public search eligibility.
Docs / Postman / snapshots
backend/cosmetology-app/docs/*, backend/cosmetology-app/docs/postman/*, backend/cosmetology-app/tests/resources/snapshots/*
Updated OpenAPI/internal specs and Postman examples; added licenseEligibility to schemas and refreshed snapshots.
Misc
backend/multi-account/README.md, staff-user helpers
Updated README IAM policy examples and small staff-user permission formatting/whitelist tweaks.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • ChiefStief
  • jlkravitz

"I hop through diffs with a cheerful beat,
When home states change I send the email treat.
Events and queues, templates all aligned,
Rabbit cheers — notifications well-defined! 🐰📬"

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/cosmetology-app/stacks/api_stack/v1_api/api_model.py (1)

1492-1514: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

licenseEligibility is missing from the public list response contract.

Line 1492+ defines the public search list row schema, but it does not include the new licenseEligibility field in either required or properties. That leaves the API model out of sync with the PR objective and can break frontend/API contract validation.

Proposed schema update
     def _public_license_search_response_schema(self):
         """Schema for public query providers response"""
         stack: AppStack = AppStack.of(self.api)
         return JsonSchema(
             type=JsonSchemaType.OBJECT,
             required=[
                 'providerId',
                 'givenName',
                 'familyName',
                 'licenseJurisdiction',
                 'compact',
                 'licenseType',
                 'licenseNumber',
+                'licenseEligibility',
             ],
             properties={
                 'providerId': JsonSchema(type=JsonSchemaType.STRING, pattern=cc_api.UUID4_FORMAT),
                 'givenName': JsonSchema(type=JsonSchemaType.STRING, min_length=1, max_length=100),
                 'familyName': JsonSchema(type=JsonSchemaType.STRING, min_length=1, max_length=100),
                 'licenseJurisdiction': JsonSchema(
                     type=JsonSchemaType.STRING, enum=stack.node.get_context('jurisdictions')
                 ),
                 'compact': JsonSchema(type=JsonSchemaType.STRING, enum=stack.node.get_context('compacts')),
                 'licenseType': JsonSchema(
                     type=JsonSchemaType.STRING,
                     description='License type or profession designation for this license row',
                 ),
                 'licenseNumber': JsonSchema(type=JsonSchemaType.STRING, min_length=1, max_length=100),
+                'licenseEligibility': JsonSchema(
+                    type=JsonSchemaType.STRING,
+                    enum=['eligible', 'ineligible'],
+                    description='Eligibility status for this license row in public search',
+                ),
             },
         )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/cosmetology-app/stacks/api_stack/v1_api/api_model.py` around lines
1492 - 1514, The public search list row schema block (the JsonSchema that lists
required=[...]) is missing the new licenseEligibility field; add
"licenseEligibility" to the required list and add a corresponding properties
entry for 'licenseEligibility' (e.g., a JsonSchema with
type=JsonSchemaType.STRING, a short description like "license eligibility status
for this license row", and sensible length/enum constraints consistent with
other fields such as min_length=1 and max_length=100 or an enum if applicable)
so the API model in api_model.py stays in sync with the PR objective.
🧹 Nitpick comments (15)
backend/cosmetology-app/lambdas/python/common/tests/unit/test_provider_record_util.py (1)

469-525: 💤 Low value

Optional: Rename test_find_best_license_* methods to reflect the renamed function.

The test method names (test_find_best_license_date_of_issuance_preferred_when_no_renewal, test_find_best_license_raises_exception_when_no_licenses, test_find_best_license_complex_scenario) still reference the old find_best_license name even though they now call find_most_recently_issued_or_renewed_license. Consider renaming for consistency with the new public API.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@backend/cosmetology-app/lambdas/python/common/tests/unit/test_provider_record_util.py`
around lines 469 - 525, The test method names still reference the old
find_best_license name; update these to match the renamed public API by renaming
test_find_best_license_date_of_issuance_preferred_when_no_renewal,
test_find_best_license_raises_exception_when_no_licenses, and related
test_find_best_license_* methods to use
find_most_recently_issued_or_renewed_license (e.g.,
test_find_most_recently_issued_or_renewed_by_dateOfIssuance_when_no_renewal,
test_find_most_recently_issued_or_renewed_raises_when_no_licenses, etc.) so test
names reflect the function
ProviderRecordUtility.find_most_recently_issued_or_renewed_license and maintain
consistency.
backend/compact-connect/tests/smoke/compact_configuration_smoke_tests.py (1)

106-116: 💤 Low value

Use str | None for the new optional compact parameters.

compact: str = None annotates the parameter as str while allowing None as the default — the rest of this codebase consistently uses str | None = None for optional string parameters. Aligning these signatures keeps type-checkers happy and matches the style used elsewhere.

♻️ Suggested diff
-def test_compact_configuration(compact: str = None):
+def test_compact_configuration(compact: str | None = None):
-def test_jurisdiction_configuration(compact: str = None, jurisdiction: str = 'ne', recreate_compact_config: bool = False):
+def test_jurisdiction_configuration(
+    compact: str | None = None, jurisdiction: str = 'ne', recreate_compact_config: bool = False
+):

Also applies to: 226-237

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/compact-connect/tests/smoke/compact_configuration_smoke_tests.py`
around lines 106 - 116, The parameter annotation for the optional compact
argument in test_compact_configuration should use the union type used elsewhere:
change the signature from compact: str = None to compact: str | None = None;
update any other optional string parameters in the same file (the other test
function(s) around the later occurrence) to follow the same pattern so
type-checkers and code style remain consistent (look for the other function
signatures mentioned in the comment and replace their `str = None` annotations
with `str | None = None`).
backend/cosmetology-app/lambdas/python/search/handlers/public_search.py (1)

47-80: ⚖️ Poor tradeoff

Eligibility helper performs a full provider-schema load per hit — verify performance and intent on validation failure.

A couple of concerns worth confirming:

  1. ProviderOpenSearchDocumentSchema().load(provider_source) is invoked once per OpenSearch hit. For a full page of results this is potentially N full deep validations on data that the indexer already produced. If the per-hit cost is non-trivial, consider whether the eligibility check can be derived directly from the raw _source (e.g., inspecting adverseActions and licenses[0]['compactEligibility'] directly), keeping the schema load as a fallback only when needed.
  2. On ValidationError, the helper currently returns INELIGIBLE. This is a reasonable fail-closed default for the public-facing API, but it does mean any indexer/schema drift will silently surface every affected provider as "ineligible" rather than failing visibly. The logger.error mitigates this somewhat — please confirm that's the intended behavior (vs. e.g. raising and letting it propagate to a 500, since the upstream try/except ValidationError in _public_query_licenses would then just skip the row).
  3. Line 76 unconditionally indexes licenses_list[0], which is safe given the indexing model (one license per provider doc), but _determine_license_eligibility is called with the raw source while _public_query_licenses separately validates source.get('licenses') is non-empty on line 117. Worth a short docstring note that this helper assumes exactly one license entry (matching the indexing model) so future readers don't accidentally call it with multi-license sources.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/cosmetology-app/lambdas/python/search/handlers/public_search.py`
around lines 47 - 80, The helper _determine_license_eligibility currently does a
full ProviderOpenSearchDocumentSchema().load(provider_source) per hit and
returns INELIGIBLE on ValidationError; change it to first attempt a cheap,
direct inspection of provider_source['adverseActions'] and
provider_source.get('licenses', [])[0]['compactEligibility'] to decide
eligibility (only invoking schema.load(...) as a fallback when those raw keys
are missing or malformed), keep the fail-closed behavior but make it explicit by
documenting in the function docstring that ValidationError intentionally maps to
CompactEligibilityStatus.INELIGIBLE and that the function assumes exactly one
license entry (indexing model), and retain the logger.error call so validation
failures remain logged.
backend/cosmetology-app/lambdas/nodejs/lib/email/email-notification-service.ts (1)

67-70: 💤 Low value

Subject line omits the new home state.

The subject is fixed at Practitioner Home State Change - ${compactConfig.compactName} with no provider/state context. The recipient (former state ops team) needs to scan a queue of these — including the practitioner name and/or destination state in the subject (consistent with other notifications in this lambda like "License Encumbrance Notification - John Doe") would substantially improve triage.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@backend/cosmetology-app/lambdas/nodejs/lib/email/email-notification-service.ts`
around lines 67 - 70, Update the subject construction in
email-notification-service.ts (the subject variable that currently uses
compactConfig.compactName) to include provider identity and the new home state;
for example, build the subject using providerFirstName and providerLastName and
formattedNewJurisdiction (e.g., `Practitioner Home State Change -
${providerFirstName} ${providerLastName} to ${formattedNewJurisdiction} -
${compactConfig.compactName}`) so recipients can triage messages from the inbox
without opening the email.
backend/cosmetology-app/lambdas/nodejs/tests/email-notification-service.test.ts (2)

1082-1094: 💤 Low value

Test fixture has jurisdiction equal to previousJurisdiction, masking a potential bug.

Both jurisdiction and previousJurisdiction are 'tx' in the sample event. Because home_state_change_events.py invokes the lambda with jurisdiction=former_home_jurisdiction (i.e., they intentionally match in production), this fixture is realistic — but it also means the test cannot distinguish the case where the email service incorrectly substitutes one for the other. Consider asserting that the recipient lookup keyed off jurisdiction='tx' matches previousJurisdiction='tx' end-to-end, or alternatively setting previousJurisdiction to a different value so the body assertion on Line 1153 (from TX to OH) catches accidental cross-wiring.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@backend/cosmetology-app/lambdas/nodejs/tests/email-notification-service.test.ts`
around lines 1082 - 1094, The fixture
SAMPLE_HOME_JURISDICTION_CHANGE_NEW_STATE_NOTIFICATION_EVENT sets both
jurisdiction and templateVariables.previousJurisdiction to 'tx', which masks
cross-wiring bugs; update the fixture so previousJurisdiction differs (e.g.,
previousJurisdiction: 'tx' and jurisdiction: 'oh' or vice versa) OR add an
explicit end-to-end assertion that the recipient lookup used the
event.jurisdiction (former_home_jurisdiction) rather than
templateVariables.previousJurisdiction—locate
SAMPLE_HOME_JURISDICTION_CHANGE_NEW_STATE_NOTIFICATION_EVENT and modify
templateVariables.previousJurisdiction (or add the recipient lookup assertion
around the email body check that expects "from TX to OH") so the test will fail
if the service substitutes the wrong field.

1160-1170: ⚡ Quick win

Add a missing-jurisdiction test case for parity.

Other State Notification describe blocks in this file (e.g., License Encumbrance State Notification, lines 284–293; Privilege Encumbrance State Notification, lines 564–573) include a test asserting the handler throws when jurisdiction is undefined. The new homeJurisdictionChangeNotification case in lambda.ts (Lines 331–333) has the same guard but no corresponding test.

🧪 Suggested test addition
         it('should throw error when required template variables are missing', async () => {
             const eventWithMissingVariables: EmailNotificationEvent = {
                 ...SAMPLE_HOME_JURISDICTION_CHANGE_NEW_STATE_NOTIFICATION_EVENT,
                 templateVariables: {}
             };

             await expect(lambda.handler(eventWithMissingVariables, {} as any))
                 .rejects
                 .toThrow('Missing required template variables for home jurisdiction change notification template.');
         });
+
+        it('should throw error when jurisdiction is missing', async () => {
+            const eventWithMissingJurisdiction: EmailNotificationEvent = {
+                ...SAMPLE_HOME_JURISDICTION_CHANGE_NEW_STATE_NOTIFICATION_EVENT,
+                jurisdiction: undefined
+            };
+
+            await expect(lambda.handler(eventWithMissingJurisdiction, {} as any))
+                .rejects
+                .toThrow('Missing required jurisdiction field for home jurisdiction change notification template.');
+        });
     });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@backend/cosmetology-app/lambdas/nodejs/tests/email-notification-service.test.ts`
around lines 1160 - 1170, Add a test that mirrors the other "State Notification"
missing-jurisdiction cases: create a copy of
SAMPLE_HOME_JURISDICTION_CHANGE_NEW_STATE_NOTIFICATION_EVENT with jurisdiction
set to undefined (or removed) and assert that calling
lambda.handler(eventWithoutJurisdiction, {} as any) rejects with the same error
thrown by the homeJurisdictionChangeNotification guard. Reference
SAMPLE_HOME_JURISDICTION_CHANGE_NEW_STATE_NOTIFICATION_EVENT and lambda.handler
(and the homeJurisdictionChangeNotification guard behavior in lambda.ts) when
adding the test to the existing describe block so the test suite covers the
missing-jurisdiction path.
backend/cosmetology-app/lambdas/python/common/cc_common/email_service_client.py (2)

399-407: 💤 Low value

licenseType is sent in the payload but unused by the email lambda.

The Python client populates templateVariables.licenseType (Line 405), but the NodeJS handler case in lambda.ts (Lines 334–340) does not validate or read licenseType, and EmailNotificationService.sendHomeJurisdictionChangeStateNotificationEmail does not accept or render it. This is either dead payload data or a missed inclusion in the email body. Decide whether to drop it from the Python payload or include the license type in the rendered email body/subject (which would also help recipients identify which credential moved when a practitioner holds multiple license types).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@backend/cosmetology-app/lambdas/python/common/cc_common/email_service_client.py`
around lines 399 - 407, The payload currently includes
templateVariables.licenseType in email_service_client.py but the NodeJS path
doesn't consume it; either remove this dead field from templateVariables in
email_service_client.py, or extend the NodeJS flow to accept and render it:
update the handler that reads the incoming payload (the case that routes to
EmailNotificationService) to extract licenseType, add a parameter to
EmailNotificationService.sendHomeJurisdictionChangeStateNotificationEmail to
accept licenseType, and update the email template rendering logic to include the
license type in subject/body; ensure all call sites and type signatures are
updated accordingly.

41-51: 💤 Low value

Type annotation inconsistent with runtime check.

provider_id: UUID is declared non-Optional on the dataclass (Line 51), but send_provider_home_state_change_email checks if template_variables.provider_id is None (Line 391). The check is unreachable for callers that respect the type, and a static type-checker will flag mypy/pyright errors on the assignment. This matches the pre-existing pattern in InvestigationNotificationTemplateVariables, so it isn't a regression — flagging only for awareness.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@backend/cosmetology-app/lambdas/python/common/cc_common/email_service_client.py`
around lines 41 - 51, The dataclass
HomeJurisdictionChangeNotificationTemplateVariables declares provider_id as UUID
but the runtime code in send_provider_home_state_change_email checks for None;
make the types consistent by changing provider_id: UUID to provider_id:
Optional[UUID] (and add the Optional import) so the None check is valid, or
alternatively remove the None check in send_provider_home_state_change_email;
update the annotation to Optional[UUID] to match the existing pattern used by
InvestigationNotificationTemplateVariables if you want to allow None.
backend/cosmetology-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_ingest.py (1)

705-722: ⚡ Quick win

Hardcoded providerId in the asserted event detail is brittle.

Line 712 hardcodes '89a6377e-c3a5-40e5-bca5-317ec854c570' rather than using the provider_id returned from self._with_ingested_license() at line 671. If the fixture (provider-ssn.json) ever changes, the entire Detail JSON string match will fail with a confusing diff. Reuse the existing variable for resilience.

♻️ Suggested fix
                 'Detail': json.dumps(
                     {
                         'compact': 'cosm',
                         'jurisdiction': 'ky',
                         'eventTime': '2024-11-08T23:59:59+00:00',
-                        'providerId': '89a6377e-c3a5-40e5-bca5-317ec854c570',
+                        'providerId': provider_id,
                         'licenseType': 'cosmetologist',
                         'formerHomeJurisdiction': 'oh',
                     }
                 ),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@backend/cosmetology-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_ingest.py`
around lines 705 - 722, The asserted event detail is brittle because it
hardcodes the provider UUID instead of using the provider_id returned by
self._with_ingested_license(); update the expected object used in the assertion
(the dict compared to home_change_entry) to reference the provider_id variable
(from the self._with_ingested_license() call) for the 'providerId' value and
ensure the json.dumps call uses that provider_id so the test remains resilient
to fixture changes.
backend/cosmetology-app/lambdas/python/common/tests/unit/test_email_service_client.py (1)

49-68: 💤 Low value

Consider comparing parsed JSON instead of exact json.dumps string.

Asserting on Payload=json.dumps({...}) couples the test to a specific dict iteration order in the production code. If the implementation ever changes the key order in its serialization (or switches json.dumps options), this assertion breaks despite the payload being semantically identical. A more resilient approach extracts the Payload kwarg, decodes it, and compares parsed dicts.

♻️ Suggested approach
-        mock_lambda_client.invoke.assert_called_once_with(
-            FunctionName='test-lambda-name',
-            InvocationType='RequestResponse',
-            Payload=json.dumps(
-                {
-                    'compact': TEST_COMPACT,
-                    'jurisdiction': TEST_FORMER_JURISDICTION,
-                    'template': 'homeJurisdictionChangeNotification',
-                    'recipientType': 'JURISDICTION_OPERATIONS_TEAM',
-                    'templateVariables': {
-                        'providerFirstName': 'Jane',
-                        'providerLastName': 'Smith',
-                        'providerId': str(TEST_PROVIDER_ID),
-                        'previousJurisdiction': TEST_FORMER_JURISDICTION,
-                        'newJurisdiction': TEST_NEW_JURISDICTION,
-                        'licenseType': 'Cosmetologist',
-                    },
-                }
-            ),
-        )
+        mock_lambda_client.invoke.assert_called_once()
+        call_kwargs = mock_lambda_client.invoke.call_args.kwargs
+        self.assertEqual('test-lambda-name', call_kwargs['FunctionName'])
+        self.assertEqual('RequestResponse', call_kwargs['InvocationType'])
+        self.assertEqual(
+            {
+                'compact': TEST_COMPACT,
+                'jurisdiction': TEST_FORMER_JURISDICTION,
+                'template': 'homeJurisdictionChangeNotification',
+                'recipientType': 'JURISDICTION_OPERATIONS_TEAM',
+                'templateVariables': {
+                    'providerFirstName': 'Jane',
+                    'providerLastName': 'Smith',
+                    'providerId': str(TEST_PROVIDER_ID),
+                    'previousJurisdiction': TEST_FORMER_JURISDICTION,
+                    'newJurisdiction': TEST_NEW_JURISDICTION,
+                    'licenseType': 'Cosmetologist',
+                },
+            },
+            json.loads(call_kwargs['Payload']),
+        )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@backend/cosmetology-app/lambdas/python/common/tests/unit/test_email_service_client.py`
around lines 49 - 68, Update the test to avoid asserting on the exact json.dumps
string: grab the actual call to mock_lambda_client.invoke (e.g. via
mock_lambda_client.invoke.call_args or call_args_list), extract the Payload
kwarg, decode it with json.loads, and compare the resulting dict to the expected
dict (including nested templateVariables) instead of comparing the raw
serialized string; keep the existing assertions for FunctionName and
InvocationType but replace the Payload equality check with a parsed-dict
comparison.
backend/cosmetology-app/tests/smoke/smoke_common.py (2)

438-464: 💤 Low value

Document precedence of jurisdictions over jurisdiction.

When both are provided, jurisdictions silently wins (line 457). That's a reasonable default but isn't reflected in the docstring, which could confuse callers debugging a wrong scope set. Consider noting the precedence in the docstring (or rejecting the case where both are supplied).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/cosmetology-app/tests/smoke/smoke_common.py` around lines 438 - 464,
The docstring for create_test_app_client does not state that the jurisdictions
parameter takes precedence over jurisdiction when both are provided; update the
docstring for create_test_app_client to explicitly document that if both
jurisdictions (list) and jurisdiction (single) are supplied, jurisdictions will
be used to build allowed_scopes (or alternatively implement validation to reject
both), so callers know the precedence of jurisdictions over jurisdiction and how
allowed_scopes is derived.

387-405: 💤 Low value

Edge case: max_attempts can be 0 if poll_interval_seconds > max_wait_time.

With max_wait_time=300 (default) the math works out fine, but if a caller passes a poll_interval_seconds larger than max_wait_time (e.g. max_wait_time=30, poll_interval_seconds=60), max_attempts becomes 0 and the function raises SmokeTestFailureException immediately without ever polling. Either guard with max(1, ...) or document the constraint in the docstring.

♻️ Suggested fix
-    max_attempts = max_wait_time // poll_interval_seconds
+    max_attempts = max(1, max_wait_time // poll_interval_seconds)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/cosmetology-app/tests/smoke/smoke_common.py` around lines 387 - 405,
The code computes max_attempts as "max_wait_time // poll_interval_seconds" which
can yield 0 when poll_interval_seconds > max_wait_time, causing the loop to skip
polling; change the computation of max_attempts to ensure at least one attempt
(e.g., max_attempts = max(1, max_wait_time // poll_interval_seconds)) or
otherwise clamp poll_interval_seconds, so the while loop using attempts <
max_attempts will always perform at least one call to query_provider_by_name
(refer to max_attempts, poll_interval_seconds, max_wait_time and the polling
loop that calls get_staff_user_auth_headers/query_provider_by_name).
backend/cosmetology-app/lambdas/python/data-events/tests/function/test_investigation_events.py (1)

97-98: 💤 Low value

Renamed method still seeds a registered provider — name is now slightly less precise.

The body still seeds compactConnectRegisteredEmailAddress (line 104) so the test specifically exercises the "registered provider" path. Dropping "registered" from the name and docstring makes it ambiguous compared with neighboring tests like test_license_investigation_listener_handles_missing_provider_records. Consider keeping "registered" or adding a brief docstring note clarifying the seeded state. Same applies to lines 161, 222, and 283.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@backend/cosmetology-app/lambdas/python/data-events/tests/function/test_investigation_events.py`
around lines 97 - 98, The test method
test_license_investigation_listener_processes_event_with_provider currently
seeds compactConnectRegisteredEmailAddress (a registered provider) but its
name/docs drop "registered", causing ambiguity; either rename the test to
include "registered" (e.g.,
test_license_investigation_listener_processes_event_with_registered_provider) or
update the docstring to explicitly state that the test seeds a registered
provider, and apply the same clarity fix to the other similar tests referenced
around lines 161, 222, and 283 so their names/docs match the seeded state.
backend/cosmetology-app/tests/smoke/license_upload_smoke_tests.py (2)

220-227: 💤 Low value

Inconsistent max_wait_seconds between caller and default.

_wait_for_home_state_change_event defaults to 720 seconds (line 112), but the only caller passes 750 (line 222). Either align the default or drop the explicit override to keep the values in sync. Same minor inconsistency exists between wait_for_provider_creation(max_wait_time=750, ...) (line 175) and the helper's default.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/cosmetology-app/tests/smoke/license_upload_smoke_tests.py` around
lines 220 - 227, Tests pass an explicit max_wait_seconds=750 to
_wait_for_home_state_change_event and wait_for_provider_creation while the
helpers default to 720; make these consistent by updating the helper defaults to
750 (change the default parameter in _wait_for_home_state_change_event and in
wait_for_provider_creation to 750) so callers can omit the explicit override and
the values remain in sync.

112-137: 💤 Low value

FilterExpression is applied after pagination — may miss events on a busy table.

_wait_for_home_state_change_event runs a single query per attempt and applies FilterExpression='providerId = :provider_id' afterwards. In DynamoDB, the filter is evaluated after the page is read but before results are returned, so if the matching event sits beyond the first page (e.g. many concurrent events on the same pk), it can be silently skipped this attempt. The polling loop will likely catch it on a later attempt, but pagination would be more robust.

In practice this smoke test runs against a sandbox with little contention, so it's a minor reliability concern rather than a bug.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/cosmetology-app/tests/smoke/license_upload_smoke_tests.py` around
lines 112 - 137, The query in _wait_for_home_state_change_event uses
FilterExpression='providerId = :provider_id' which is applied client-side per
page and can miss matches on subsequent pages; instead, inside each attempt
iterate through all query pages (use response.get('LastEvaluatedKey') /
ExclusiveStartKey) for the KeyConditionExpression
f'COMPACT#{COMPACT}#JURISDICTION#{HOME_STATE_CHANGE_NEW_JURISDICTION}' and scan
each page's Items for an item whose 'providerId' equals the provider_id
argument; stop and return the matching_event if found, otherwise continue
pagination until exhausted, then sleep and retry the outer attempt loop—keep
ConsistentRead=True and the existing logging around provider.homeStateChange.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/compact-connect/lambdas/python/data-events/requirements-dev.txt`:
- Line 61: The PR pins urllib3==2.7.0 which drops Python 3.9/PyPy3.10 support
and requires pyOpenSSL>=19.0.0; before merging, verify the CI/dev environment
and any consumers support Python >=3.10 (or update target interpreters), add a
note in requirements-dev.txt or project README documenting the Python minimum,
and ensure pyOpenSSL is upgraded (add/raise its version in requirements-dev.txt
or a constraints file) so builds and tests remain green.

In
`@backend/cosmetology-app/lambdas/nodejs/lib/email/email-notification-service.ts`:
- Around line 52-60: The current code assumes getJurisdictionRecipients returns
an array but if jurisdictionConfig.jurisdictionOperationsTeamEmails is missing
it can return undefined causing a TypeError on recipients.length; update the
call-site or the getJurisdictionRecipients implementation so recipients is
always an array (e.g., coerce undefined to [] or return [] from
getJurisdictionRecipients) and then keep the existing check (change the
condition to check for an empty array or falsy/length: recipients = recipients
?? []; if (recipients.length === 0) throw ...). Ensure references:
getJurisdictionRecipients, recipients, and
jurisdictionConfig.jurisdictionOperationsTeamEmails are the places to adjust.

In
`@backend/cosmetology-app/lambdas/python/data-events/handlers/investigation_events.py`:
- Around line 145-146: The handlers in investigation_events.py call
config.data_client.get_provider_top_level_record(...) directly (at the four
sites around lines 146, 213, 274, 341) and thus miss the error-catching/logging
behavior that encumbrance_events.py enforces via its _get_provider_records(...)
wrapper; update investigation_events.py to either call the existing
_get_provider_records(...) helper (or add an equivalent wrapper in
investigation_events.py) so that calls to get_provider_top_level_record are
wrapped to catch exceptions, log failures with processLogger.error (or the
module's logger) and then re-raise, ensuring consistent error visibility for
`@sqs_handler-decorated` handlers.

In
`@backend/cosmetology-app/lambdas/python/data-events/tests/function/test_home_state_change_events.py`:
- Around line 22-23: Update the stale class docstring on
TestHomeStateChangeEvents to accurately describe this test suite (it covers home
state change event handlers, not investigation event handlers); locate the
TestHomeStateChangeEvents class and replace the docstring "Test suite for
investigation event handlers." with a concise description like "Test suite for
home state change event handlers." to reflect its purpose.
- Around line 46-47: Method name has a typo: rename
test_license_homes_state_change_listener_sends_notification_to_former_state to
test_license_home_state_change_listener_sends_notification_to_former_state to
match the docstring and project naming (align with
home_state_change_notification_listener); update any references or test
discovery expectations that use the old name so the test still runs and keep the
docstring unchanged.

In `@backend/cosmetology-app/lambdas/python/search/handlers/public_search.py`:
- Around line 40-44: The tests lack explicit coverage that privilege-level
adverse actions are included in the top-level adverseActions field of OpenSearch
documents; add a new test method in TestGenerateOpenSearchDocuments that uses
get_adverse_action_records() (or the existing test fixtures used by
TestGenerateOpenSearchDocuments) to create at least one privilege-level adverse
action record, call the document generation routine that produces docs (the same
flow used by existing tests like test_license_adverse_actions_included), and
assert that docs[0]['adverseActions'] contains the privilege-level record; also
update or rename any misleading variables like provider_level_adverse_actions in
the test to reflect that adverseActions aggregates provider, license, and
privilege records so the intent is clear.

In `@backend/cosmetology-app/tests/smoke/license_upload_smoke_tests.py`:
- Around line 353-354: The error log in the except block for
SmokeTestFailureException still says "License record upload" but the test flow
was renamed; update the logger.error call inside the except
SmokeTestFailureException handler (in license_upload_smoke_tests.py) to use the
current test name by replacing the message string with the new flow name (e.g.,
"license upload smoke test failed" or the exact renamed flow) so the log matches
the test being run while preserving inclusion of str(e).

---

Outside diff comments:
In `@backend/cosmetology-app/stacks/api_stack/v1_api/api_model.py`:
- Around line 1492-1514: The public search list row schema block (the JsonSchema
that lists required=[...]) is missing the new licenseEligibility field; add
"licenseEligibility" to the required list and add a corresponding properties
entry for 'licenseEligibility' (e.g., a JsonSchema with
type=JsonSchemaType.STRING, a short description like "license eligibility status
for this license row", and sensible length/enum constraints consistent with
other fields such as min_length=1 and max_length=100 or an enum if applicable)
so the API model in api_model.py stays in sync with the PR objective.

---

Nitpick comments:
In `@backend/compact-connect/tests/smoke/compact_configuration_smoke_tests.py`:
- Around line 106-116: The parameter annotation for the optional compact
argument in test_compact_configuration should use the union type used elsewhere:
change the signature from compact: str = None to compact: str | None = None;
update any other optional string parameters in the same file (the other test
function(s) around the later occurrence) to follow the same pattern so
type-checkers and code style remain consistent (look for the other function
signatures mentioned in the comment and replace their `str = None` annotations
with `str | None = None`).

In
`@backend/cosmetology-app/lambdas/nodejs/lib/email/email-notification-service.ts`:
- Around line 67-70: Update the subject construction in
email-notification-service.ts (the subject variable that currently uses
compactConfig.compactName) to include provider identity and the new home state;
for example, build the subject using providerFirstName and providerLastName and
formattedNewJurisdiction (e.g., `Practitioner Home State Change -
${providerFirstName} ${providerLastName} to ${formattedNewJurisdiction} -
${compactConfig.compactName}`) so recipients can triage messages from the inbox
without opening the email.

In
`@backend/cosmetology-app/lambdas/nodejs/tests/email-notification-service.test.ts`:
- Around line 1082-1094: The fixture
SAMPLE_HOME_JURISDICTION_CHANGE_NEW_STATE_NOTIFICATION_EVENT sets both
jurisdiction and templateVariables.previousJurisdiction to 'tx', which masks
cross-wiring bugs; update the fixture so previousJurisdiction differs (e.g.,
previousJurisdiction: 'tx' and jurisdiction: 'oh' or vice versa) OR add an
explicit end-to-end assertion that the recipient lookup used the
event.jurisdiction (former_home_jurisdiction) rather than
templateVariables.previousJurisdiction—locate
SAMPLE_HOME_JURISDICTION_CHANGE_NEW_STATE_NOTIFICATION_EVENT and modify
templateVariables.previousJurisdiction (or add the recipient lookup assertion
around the email body check that expects "from TX to OH") so the test will fail
if the service substitutes the wrong field.
- Around line 1160-1170: Add a test that mirrors the other "State Notification"
missing-jurisdiction cases: create a copy of
SAMPLE_HOME_JURISDICTION_CHANGE_NEW_STATE_NOTIFICATION_EVENT with jurisdiction
set to undefined (or removed) and assert that calling
lambda.handler(eventWithoutJurisdiction, {} as any) rejects with the same error
thrown by the homeJurisdictionChangeNotification guard. Reference
SAMPLE_HOME_JURISDICTION_CHANGE_NEW_STATE_NOTIFICATION_EVENT and lambda.handler
(and the homeJurisdictionChangeNotification guard behavior in lambda.ts) when
adding the test to the existing describe block so the test suite covers the
missing-jurisdiction path.

In
`@backend/cosmetology-app/lambdas/python/common/cc_common/email_service_client.py`:
- Around line 399-407: The payload currently includes
templateVariables.licenseType in email_service_client.py but the NodeJS path
doesn't consume it; either remove this dead field from templateVariables in
email_service_client.py, or extend the NodeJS flow to accept and render it:
update the handler that reads the incoming payload (the case that routes to
EmailNotificationService) to extract licenseType, add a parameter to
EmailNotificationService.sendHomeJurisdictionChangeStateNotificationEmail to
accept licenseType, and update the email template rendering logic to include the
license type in subject/body; ensure all call sites and type signatures are
updated accordingly.
- Around line 41-51: The dataclass
HomeJurisdictionChangeNotificationTemplateVariables declares provider_id as UUID
but the runtime code in send_provider_home_state_change_email checks for None;
make the types consistent by changing provider_id: UUID to provider_id:
Optional[UUID] (and add the Optional import) so the None check is valid, or
alternatively remove the None check in send_provider_home_state_change_email;
update the annotation to Optional[UUID] to match the existing pattern used by
InvestigationNotificationTemplateVariables if you want to allow None.

In
`@backend/cosmetology-app/lambdas/python/common/tests/unit/test_email_service_client.py`:
- Around line 49-68: Update the test to avoid asserting on the exact json.dumps
string: grab the actual call to mock_lambda_client.invoke (e.g. via
mock_lambda_client.invoke.call_args or call_args_list), extract the Payload
kwarg, decode it with json.loads, and compare the resulting dict to the expected
dict (including nested templateVariables) instead of comparing the raw
serialized string; keep the existing assertions for FunctionName and
InvocationType but replace the Payload equality check with a parsed-dict
comparison.

In
`@backend/cosmetology-app/lambdas/python/common/tests/unit/test_provider_record_util.py`:
- Around line 469-525: The test method names still reference the old
find_best_license name; update these to match the renamed public API by renaming
test_find_best_license_date_of_issuance_preferred_when_no_renewal,
test_find_best_license_raises_exception_when_no_licenses, and related
test_find_best_license_* methods to use
find_most_recently_issued_or_renewed_license (e.g.,
test_find_most_recently_issued_or_renewed_by_dateOfIssuance_when_no_renewal,
test_find_most_recently_issued_or_renewed_raises_when_no_licenses, etc.) so test
names reflect the function
ProviderRecordUtility.find_most_recently_issued_or_renewed_license and maintain
consistency.

In
`@backend/cosmetology-app/lambdas/python/data-events/tests/function/test_investigation_events.py`:
- Around line 97-98: The test method
test_license_investigation_listener_processes_event_with_provider currently
seeds compactConnectRegisteredEmailAddress (a registered provider) but its
name/docs drop "registered", causing ambiguity; either rename the test to
include "registered" (e.g.,
test_license_investigation_listener_processes_event_with_registered_provider) or
update the docstring to explicitly state that the test seeds a registered
provider, and apply the same clarity fix to the other similar tests referenced
around lines 161, 222, and 283 so their names/docs match the seeded state.

In
`@backend/cosmetology-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_ingest.py`:
- Around line 705-722: The asserted event detail is brittle because it hardcodes
the provider UUID instead of using the provider_id returned by
self._with_ingested_license(); update the expected object used in the assertion
(the dict compared to home_change_entry) to reference the provider_id variable
(from the self._with_ingested_license() call) for the 'providerId' value and
ensure the json.dumps call uses that provider_id so the test remains resilient
to fixture changes.

In `@backend/cosmetology-app/lambdas/python/search/handlers/public_search.py`:
- Around line 47-80: The helper _determine_license_eligibility currently does a
full ProviderOpenSearchDocumentSchema().load(provider_source) per hit and
returns INELIGIBLE on ValidationError; change it to first attempt a cheap,
direct inspection of provider_source['adverseActions'] and
provider_source.get('licenses', [])[0]['compactEligibility'] to decide
eligibility (only invoking schema.load(...) as a fallback when those raw keys
are missing or malformed), keep the fail-closed behavior but make it explicit by
documenting in the function docstring that ValidationError intentionally maps to
CompactEligibilityStatus.INELIGIBLE and that the function assumes exactly one
license entry (indexing model), and retain the logger.error call so validation
failures remain logged.

In `@backend/cosmetology-app/tests/smoke/license_upload_smoke_tests.py`:
- Around line 220-227: Tests pass an explicit max_wait_seconds=750 to
_wait_for_home_state_change_event and wait_for_provider_creation while the
helpers default to 720; make these consistent by updating the helper defaults to
750 (change the default parameter in _wait_for_home_state_change_event and in
wait_for_provider_creation to 750) so callers can omit the explicit override and
the values remain in sync.
- Around line 112-137: The query in _wait_for_home_state_change_event uses
FilterExpression='providerId = :provider_id' which is applied client-side per
page and can miss matches on subsequent pages; instead, inside each attempt
iterate through all query pages (use response.get('LastEvaluatedKey') /
ExclusiveStartKey) for the KeyConditionExpression
f'COMPACT#{COMPACT}#JURISDICTION#{HOME_STATE_CHANGE_NEW_JURISDICTION}' and scan
each page's Items for an item whose 'providerId' equals the provider_id
argument; stop and return the matching_event if found, otherwise continue
pagination until exhausted, then sleep and retry the outer attempt loop—keep
ConsistentRead=True and the existing logging around provider.homeStateChange.

In `@backend/cosmetology-app/tests/smoke/smoke_common.py`:
- Around line 438-464: The docstring for create_test_app_client does not state
that the jurisdictions parameter takes precedence over jurisdiction when both
are provided; update the docstring for create_test_app_client to explicitly
document that if both jurisdictions (list) and jurisdiction (single) are
supplied, jurisdictions will be used to build allowed_scopes (or alternatively
implement validation to reject both), so callers know the precedence of
jurisdictions over jurisdiction and how allowed_scopes is derived.
- Around line 387-405: The code computes max_attempts as "max_wait_time //
poll_interval_seconds" which can yield 0 when poll_interval_seconds >
max_wait_time, causing the loop to skip polling; change the computation of
max_attempts to ensure at least one attempt (e.g., max_attempts = max(1,
max_wait_time // poll_interval_seconds)) or otherwise clamp
poll_interval_seconds, so the while loop using attempts < max_attempts will
always perform at least one call to query_provider_by_name (refer to
max_attempts, poll_interval_seconds, max_wait_time and the polling loop that
calls get_staff_user_auth_headers/query_provider_by_name).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c71e096f-0226-409f-9fef-0a499dffaa13

📥 Commits

Reviewing files that changed from the base of the PR and between f5065f5 and 0d07dc2.

⛔ Files ignored due to path filters (2)
  • backend/compact-connect/lambdas/nodejs/yarn.lock is excluded by !**/yarn.lock, !**/*.lock
  • backend/cosmetology-app/lambdas/nodejs/yarn.lock is excluded by !**/yarn.lock, !**/*.lock
📒 Files selected for processing (75)
  • backend/compact-connect/lambdas/nodejs/package.json
  • backend/compact-connect/lambdas/python/cognito-backup/requirements-dev.txt
  • backend/compact-connect/lambdas/python/common/requirements-dev.txt
  • backend/compact-connect/lambdas/python/common/requirements.txt
  • backend/compact-connect/lambdas/python/compact-configuration/requirements-dev.txt
  • backend/compact-connect/lambdas/python/custom-resources/requirements-dev.txt
  • backend/compact-connect/lambdas/python/data-events/requirements-dev.txt
  • backend/compact-connect/lambdas/python/disaster-recovery/requirements-dev.txt
  • backend/compact-connect/lambdas/python/feature-flag/requirements-dev.txt
  • backend/compact-connect/lambdas/python/feature-flag/requirements.txt
  • backend/compact-connect/lambdas/python/provider-data-v1/requirements-dev.txt
  • backend/compact-connect/lambdas/python/purchases/requirements-dev.in
  • backend/compact-connect/lambdas/python/purchases/requirements-dev.txt
  • backend/compact-connect/lambdas/python/purchases/requirements.in
  • backend/compact-connect/lambdas/python/purchases/requirements.txt
  • backend/compact-connect/lambdas/python/search/requirements-dev.txt
  • backend/compact-connect/lambdas/python/search/requirements.txt
  • backend/compact-connect/lambdas/python/staff-user-pre-token/requirements-dev.txt
  • backend/compact-connect/lambdas/python/staff-users/requirements-dev.txt
  • backend/compact-connect/requirements-dev.txt
  • backend/compact-connect/tests/smoke/compact_configuration_smoke_tests.py
  • backend/compact-connect/tests/smoke/purchasing_privileges_smoke_tests.py
  • backend/cosmetology-app/lambdas/nodejs/email-notification-service/lambda.ts
  • backend/cosmetology-app/lambdas/nodejs/lib/email/base-email-service.ts
  • backend/cosmetology-app/lambdas/nodejs/lib/email/email-notification-service.ts
  • backend/cosmetology-app/lambdas/nodejs/lib/email/index.ts
  • backend/cosmetology-app/lambdas/nodejs/package.json
  • backend/cosmetology-app/lambdas/nodejs/tests/email-notification-service.test.ts
  • backend/cosmetology-app/lambdas/python/cognito-backup/requirements-dev.txt
  • backend/cosmetology-app/lambdas/python/cognito-backup/requirements.txt
  • backend/cosmetology-app/lambdas/python/common/cc_common/data_model/provider_record_util.py
  • backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/data_event/api.py
  • backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/license/api.py
  • backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/provider/api.py
  • backend/cosmetology-app/lambdas/python/common/cc_common/email_service_client.py
  • backend/cosmetology-app/lambdas/python/common/cc_common/event_bus_client.py
  • backend/cosmetology-app/lambdas/python/common/requirements-dev.txt
  • backend/cosmetology-app/lambdas/python/common/requirements.txt
  • backend/cosmetology-app/lambdas/python/common/tests/unit/test_email_service_client.py
  • backend/cosmetology-app/lambdas/python/common/tests/unit/test_provider_record_util.py
  • backend/cosmetology-app/lambdas/python/compact-configuration/requirements-dev.txt
  • backend/cosmetology-app/lambdas/python/custom-resources/requirements-dev.txt
  • backend/cosmetology-app/lambdas/python/data-events/handlers/encumbrance_events.py
  • backend/cosmetology-app/lambdas/python/data-events/handlers/home_state_change_events.py
  • backend/cosmetology-app/lambdas/python/data-events/handlers/investigation_events.py
  • backend/cosmetology-app/lambdas/python/data-events/requirements-dev.txt
  • backend/cosmetology-app/lambdas/python/data-events/tests/function/test_home_state_change_events.py
  • backend/cosmetology-app/lambdas/python/data-events/tests/function/test_investigation_events.py
  • backend/cosmetology-app/lambdas/python/disaster-recovery/requirements-dev.txt
  • backend/cosmetology-app/lambdas/python/feature-flag/requirements-dev.txt
  • backend/cosmetology-app/lambdas/python/feature-flag/requirements.txt
  • backend/cosmetology-app/lambdas/python/provider-data-v1/handlers/ingest.py
  • backend/cosmetology-app/lambdas/python/provider-data-v1/requirements-dev.txt
  • backend/cosmetology-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_ingest.py
  • backend/cosmetology-app/lambdas/python/search/handlers/public_search.py
  • backend/cosmetology-app/lambdas/python/search/opensearch_client.py
  • backend/cosmetology-app/lambdas/python/search/requirements-dev.txt
  • backend/cosmetology-app/lambdas/python/search/requirements.txt
  • backend/cosmetology-app/lambdas/python/search/tests/function/test_manage_opensearch_indices.py
  • backend/cosmetology-app/lambdas/python/search/tests/function/test_populate_provider_documents.py
  • backend/cosmetology-app/lambdas/python/search/tests/function/test_provider_update_ingest.py
  • backend/cosmetology-app/lambdas/python/search/tests/function/test_public_search_providers.py
  • backend/cosmetology-app/lambdas/python/staff-user-pre-token/requirements-dev.txt
  • backend/cosmetology-app/lambdas/python/staff-user-pre-token/tests/test_user_scopes.py
  • backend/cosmetology-app/lambdas/python/staff-user-pre-token/user_data.py
  • backend/cosmetology-app/lambdas/python/staff-users/requirements-dev.txt
  • backend/cosmetology-app/requirements-dev.txt
  • backend/cosmetology-app/stacks/api_stack/v1_api/api_model.py
  • backend/cosmetology-app/stacks/notification_stack.py
  • backend/cosmetology-app/tests/app/test_notification_stack.py
  • backend/cosmetology-app/tests/smoke/license_upload_smoke_tests.py
  • backend/cosmetology-app/tests/smoke/smoke_common.py
  • backend/multi-account/README.md
  • backend/multi-account/backups/requirements-dev.txt
  • backend/multi-account/control-tower/requirements-dev.txt
💤 Files with no reviewable changes (1)
  • backend/cosmetology-app/lambdas/nodejs/lib/email/base-email-service.ts

Comment thread backend/cosmetology-app/tests/smoke/license_upload_smoke_tests.py
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
backend/cosmetology-app/docs/search-internal/api-specification/latest-oas30.json (1)

343-348: ⚡ Quick win

Consider adding maxItems constraint to error arrays.

The nested error message arrays lack a maxItems constraint, which could allow unbounded arrays in error responses.

Based on learnings, the static analysis tool Checkov flagged this. Consider adding a reasonable limit:

🛡️ Add maxItems constraint
                 "items": {
+                  "maxItems": 100,
                   "type": "array",
                   "description": "List of error messages for a field",
                   "items": {
                     "type": "string"
                   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@backend/cosmetology-app/docs/search-internal/api-specification/latest-oas30.json`
around lines 343 - 348, The OpenAPI schema has nested error message arrays
without a maxItems constraint; locate the error array schemas near keys like
"licenseType" and "providerId" and add a reasonable "maxItems" (e.g., 50 or 100)
to each array schema that models error lists (commonly named "errors",
"errorMessages", "messages" or similar) so arrays cannot be unbounded; ensure
you add the "maxItems" property alongside the existing "type": "array"
definitions and keep the numeric cap consistent across all error-list schemas.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/cosmetology-app/docs/api-specification/latest-oas30.json`:
- Line 5: The PR modified the wrong OpenAPI file (the State license-management
spec) instead of the Search API; locate the API spec that defines the
/v1/compacts/{compact}/providers/search endpoint and add the new
licenseEligibility field to the provider list response schema (e.g., the
provider compact item schema or the search response schema and its example),
update components/schemas and any examples to include licenseEligibility with
its type and description, and remove/revert the unintended change from the State
API spec so only the search-internal specification contains the new field.

---

Nitpick comments:
In
`@backend/cosmetology-app/docs/search-internal/api-specification/latest-oas30.json`:
- Around line 343-348: The OpenAPI schema has nested error message arrays
without a maxItems constraint; locate the error array schemas near keys like
"licenseType" and "providerId" and add a reasonable "maxItems" (e.g., 50 or 100)
to each array schema that models error lists (commonly named "errors",
"errorMessages", "messages" or similar) so arrays cannot be unbounded; ensure
you add the "maxItems" property alongside the existing "type": "array"
definitions and keep the numeric cap consistent across all error-list schemas.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ec6bf3ff-23d8-4f0e-9f7c-7280d299eb10

📥 Commits

Reviewing files that changed from the base of the PR and between 0d07dc2 and c3ff692.

📒 Files selected for processing (12)
  • backend/compact-connect/tests/smoke/compact_configuration_smoke_tests.py
  • backend/cosmetology-app/docs/api-specification/latest-oas30.json
  • backend/cosmetology-app/docs/internal/api-specification/latest-oas30.json
  • backend/cosmetology-app/docs/internal/postman/postman-collection.json
  • backend/cosmetology-app/docs/postman/postman-collection.json
  • backend/cosmetology-app/docs/search-internal/api-specification/latest-oas30.json
  • backend/cosmetology-app/docs/search-internal/postman/postman-collection.json
  • backend/cosmetology-app/lambdas/python/common/cc_common/data_model/provider_record_util.py
  • backend/cosmetology-app/lambdas/python/common/tests/unit/test_provider_record_util.py
  • backend/cosmetology-app/lambdas/python/search/handlers/public_search.py
  • backend/cosmetology-app/stacks/api_stack/v1_api/api_model.py
  • backend/cosmetology-app/tests/resources/snapshots/PUBLIC_QUERY_PROVIDERS_RESPONSE_SCHEMA.json
🚧 Files skipped from review as they are similar to previous changes (3)
  • backend/cosmetology-app/lambdas/python/common/cc_common/data_model/provider_record_util.py
  • backend/cosmetology-app/lambdas/python/search/handlers/public_search.py
  • backend/compact-connect/tests/smoke/compact_configuration_smoke_tests.py

"info": {
"title": "StateApi",
"version": "2026-03-13T17:32:08Z"
"version": "2026-02-16T16:53:09Z"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Locate the public search API specification

echo "=== Finding all API specifications ==="
fd 'latest-oas' backend/cosmetology-app/docs

echo -e "\n=== Check for public search endpoints in each spec ==="
for spec in $(fd 'latest-oas.*json' backend/cosmetology-app/docs); do
  echo "=== $spec ==="
  jq -r '.paths | keys[]' "$spec" 2>/dev/null | grep -i search || echo "No search endpoints"
done

echo -e "\n=== Search for GET endpoints that return provider/license lists ==="
rg -n '"get":|GET' backend/cosmetology-app/docs --type json -A10 | rg -A10 'provider|license' | head -50

Repository: csg-org/CompactConnect

Length of output: 7731


🏁 Script executed:

#!/bin/bash
# Check the search API specification for licenseEligibility field
echo "=== Checking search-internal API spec ==="
jq -r '.components.schemas | keys[]' backend/cosmetology-app/docs/search-internal/api-specification/latest-oas30.json 2>/dev/null | head -20

echo -e "\n=== Check for licenseEligibility field in search API ==="
grep -n "licenseEligibility" backend/cosmetology-app/docs/search-internal/api-specification/latest-oas30.json || echo "Not found in search-internal spec"

echo -e "\n=== Version timestamp in search-internal spec ==="
head -10 backend/cosmetology-app/docs/search-internal/api-specification/latest-oas30.json | jq '.info.version'

echo -e "\n=== Get provider-related response schemas in search API ==="
jq '.paths[] | .get.responses."200".content."application/json".schema' backend/cosmetology-app/docs/search-internal/api-specification/latest-oas30.json 2>/dev/null | head -50

Repository: csg-org/CompactConnect

Length of output: 443


🏁 Script executed:

#!/bin/bash
# Check the file validity and structure
echo "=== File info for search-internal spec ==="
wc -l backend/cosmetology-app/docs/search-internal/api-specification/latest-oas30.json
stat backend/cosmetology-app/docs/search-internal/api-specification/latest-oas30.json | grep -E "Size|Modify"

echo -e "\n=== Last 20 lines of search-internal spec ==="
tail -20 backend/cosmetology-app/docs/search-internal/api-specification/latest-oas30.json

echo -e "\n=== First 20 lines of search-internal spec ==="
head -20 backend/cosmetology-app/docs/search-internal/api-specification/latest-oas30.json

echo -e "\n=== Check if both specs have same timestamp ==="
echo "State API (license mgmt):"
head -10 backend/cosmetology-app/docs/api-specification/latest-oas30.json | grep version
echo -e "\nSearch Internal API:"
head -10 backend/cosmetology-app/docs/search-internal/api-specification/latest-oas30.json | grep version

Repository: csg-org/CompactConnect

Length of output: 1540


🏁 Script executed:

#!/bin/bash
# Check the search API response schema
echo "=== Search API response schema ==="
jq '.paths["/v1/compacts/{compact}/providers/search"].post.responses."200".content."application/json".schema' backend/cosmetology-app/docs/search-internal/api-specification/latest-oas30.json 2>/dev/null

echo -e "\n=== Search for licenseEligibility anywhere in search API ==="
grep -c "licenseEligibility" backend/cosmetology-app/docs/search-internal/api-specification/latest-oas30.json && echo "Found licenseEligibility" || echo "licenseEligibility NOT found"

echo -e "\n=== Check license-related schemas in search API ==="
jq '.components.schemas | keys[] | select(contains("License") or contains("license"))' backend/cosmetology-app/docs/search-internal/api-specification/latest-oas30.json 2>/dev/null

Repository: csg-org/CompactConnect

Length of output: 307


This file is the State API specification for license management, not the search API that handles public provider listings.

The file under review (backend/cosmetology-app/docs/api-specification/latest-oas30.json) is the State API specification for license management endpoints (POST licenses, GET bulk-upload). The PR objective requires updating the public search API response to add a licenseEligibility field. The correct API specification is backend/cosmetology-app/docs/search-internal/api-specification/latest-oas30.json, which contains the /v1/compacts/{compact}/providers/search endpoint that handles provider list-view responses. Verify that the correct API file has been modified per the PR objectives.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/cosmetology-app/docs/api-specification/latest-oas30.json` at line 5,
The PR modified the wrong OpenAPI file (the State license-management spec)
instead of the Search API; locate the API spec that defines the
/v1/compacts/{compact}/providers/search endpoint and add the new
licenseEligibility field to the provider list response schema (e.g., the
provider compact item schema or the search response schema and its example),
update components/schemas and any examples to include licenseEligibility with
its type and description, and remove/revert the unintended change from the State
API spec so only the search-internal specification contains the new field.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (1)
backend/cosmetology-app/tests/smoke/smoke_common.py (1)

530-537: ⚡ Quick win

Normalize jurisdiction scopes to lowercase before client creation.

Scope construction currently uses input casing directly. Normalizing helps avoid mismatched scopes when callers pass uppercase/mixed-case jurisdictions.

Suggested fix
-        jurisdiction_list = jurisdictions if jurisdictions else ([jurisdiction] if jurisdiction else [])
+        jurisdiction_list = jurisdictions if jurisdictions else ([jurisdiction] if jurisdiction else [])
+        jurisdiction_list = [j.lower() for j in jurisdiction_list]
@@
         allowed_scopes = [
             f'{compact}/readGeneral',
             *[f'{jurisdiction}/{compact}.write' for jurisdiction in jurisdiction_list],
         ]

Based on learnings, jurisdiction values in backend/cosmetology-app should follow lowercase normalization for consistency.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/cosmetology-app/tests/smoke/smoke_common.py` around lines 530 - 537,
Normalize jurisdiction values to lowercase before building allowed_scopes:
transform jurisdiction_list (created from jurisdictions or jurisdiction) to a
list of lowercased strings (e.g., map .lower() over each element) and then use
that normalized list when constructing allowed_scopes (the list comprehension
f'{jurisdiction}/{compact}.write' and the general read scope). Update references
to jurisdiction_list and allowed_scopes accordingly so scopes are always
generated from lowercase jurisdiction values.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/cosmetology-app/tests/smoke/public_search_smoke_tests.py`:
- Around line 132-136: The update_item calls on provider_user_table (e.g., the
call using license_partition_and_sort_key and EXPIRED_DATE_FOR_TEST) are missing
a ConditionExpression and can accidentally upsert if the key is wrong; modify
each provider_user_table.update_item (including the similar calls around the
same area) to include a ConditionExpression that asserts the item exists (for
example attribute_exists on the partition/sort key or a known attribute) and add
any needed ExpressionAttributeNames/Values so DynamoDB will fail instead of
creating a new item when the key is missing or incorrect.

In `@backend/cosmetology-app/tests/smoke/smoke_common.py`:
- Around line 274-276: The current failure handling calls response.json()
unconditionally which will raise if the body is non-JSON; update the POST
handling around the variable response so that when response.status_code != 200
you attempt to parse JSON in a try/except and fall back to response.text() (or a
safe string) and include that fallback body plus response.status_code in the
SmokeTestFailureException message; also avoid calling response.json() twice by
caching the parsed body (e.g., parsed_body) and set page_response_body from that
only when status_code == 200; apply the same pattern to the other occurrence
around lines 295-297.
- Around line 440-443: Ensure poll_interval_seconds is validated and
max_attempts cannot be zero: in the function that accepts max_wait_time,
staff_user_email, poll_interval_seconds validate poll_interval_seconds > 0
(raise or default to a sane positive value) to avoid division by zero, and
compute max_attempts from max_wait_time and poll_interval_seconds using logic
that guarantees at least one attempt (e.g., ceil division or max(1, ...)) so
that when max_wait_time < poll_interval_seconds you still perform one lookup;
update any code that uses the computed max_attempts variable to rely on this
validated/adjusted value.

---

Nitpick comments:
In `@backend/cosmetology-app/tests/smoke/smoke_common.py`:
- Around line 530-537: Normalize jurisdiction values to lowercase before
building allowed_scopes: transform jurisdiction_list (created from jurisdictions
or jurisdiction) to a list of lowercased strings (e.g., map .lower() over each
element) and then use that normalized list when constructing allowed_scopes (the
list comprehension f'{jurisdiction}/{compact}.write' and the general read
scope). Update references to jurisdiction_list and allowed_scopes accordingly so
scopes are always generated from lowercase jurisdiction values.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9bcc4f44-11d8-4f64-94cf-873b7102d33b

📥 Commits

Reviewing files that changed from the base of the PR and between c3ff692 and 390f335.

📒 Files selected for processing (2)
  • backend/cosmetology-app/tests/smoke/public_search_smoke_tests.py
  • backend/cosmetology-app/tests/smoke/smoke_common.py

Comment thread backend/cosmetology-app/tests/smoke/public_search_smoke_tests.py
Comment thread backend/cosmetology-app/tests/smoke/smoke_common.py
Comment on lines +440 to 443
max_wait_time: int = 300,
staff_user_email: str | None = None,
poll_interval_seconds: int = 30,
):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate poll interval and avoid zero-attempt polling.

Line [462] can compute max_attempts as 0 when max_wait_time < poll_interval_seconds, and poll_interval_seconds=0 will crash with division by zero. This can fail before any lookup attempt.

Suggested fix
 def wait_for_provider_creation(
@@
-    max_attempts = max_wait_time // poll_interval_seconds
+    if poll_interval_seconds <= 0:
+        raise SmokeTestFailureException('poll_interval_seconds must be greater than 0')
+    max_attempts = max(1, (max_wait_time + poll_interval_seconds - 1) // poll_interval_seconds)

Also applies to: 462-462

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/cosmetology-app/tests/smoke/smoke_common.py` around lines 440 - 443,
Ensure poll_interval_seconds is validated and max_attempts cannot be zero: in
the function that accepts max_wait_time, staff_user_email, poll_interval_seconds
validate poll_interval_seconds > 0 (raise or default to a sane positive value)
to avoid division by zero, and compute max_attempts from max_wait_time and
poll_interval_seconds using logic that guarantees at least one attempt (e.g.,
ceil division or max(1, ...)) so that when max_wait_time < poll_interval_seconds
you still perform one lookup; update any code that uses the computed
max_attempts variable to rely on this validated/adjusted value.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/license/api.py (1)

57-60: ⚡ Quick win

Add regression assertions for expired-license eligibility correction.

This path now mutates compactEligibility; add explicit unit assertions for compactEligibility == ineligible in API schema expiration tests so this behavior can’t regress silently.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/license/api.py`
around lines 57 - 60, The expiration branch now mutates
in_data['compactEligibility'] to CompactEligibilityStatus.INELIGIBLE when
expiration_date < config.expiration_resolution_date; add regression assertions
in the API schema expiration tests to assert that compactEligibility ==
CompactEligibilityStatus.INELIGIBLE (and licenseStatus ==
ActiveInactiveStatus.INACTIVE) for expired-license cases so this behavior cannot
regress silently—update the relevant test(s) that exercise the expiration_date <
config.expiration_resolution_date path to include these explicit assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In
`@backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/license/api.py`:
- Around line 57-60: The expiration branch now mutates
in_data['compactEligibility'] to CompactEligibilityStatus.INELIGIBLE when
expiration_date < config.expiration_resolution_date; add regression assertions
in the API schema expiration tests to assert that compactEligibility ==
CompactEligibilityStatus.INELIGIBLE (and licenseStatus ==
ActiveInactiveStatus.INACTIVE) for expired-license cases so this behavior cannot
regress silently—update the relevant test(s) that exercise the expiration_date <
config.expiration_resolution_date path to include these explicit assertions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 49bfdd78-da08-4001-93f7-603cbfe4d774

📥 Commits

Reviewing files that changed from the base of the PR and between 390f335 and 8d3d4d3.

📒 Files selected for processing (9)
  • backend/cosmetology-app/lambdas/python/common/cc_common/data_model/provider_record_util.py
  • backend/cosmetology-app/lambdas/python/common/cc_common/data_model/schema/license/api.py
  • backend/cosmetology-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_license.py
  • backend/cosmetology-app/lambdas/python/common/tests/unit/test_data_model/test_schema/test_provider.py
  • backend/cosmetology-app/lambdas/python/common/tests/unit/test_provider_record_util.py
  • backend/cosmetology-app/lambdas/python/search/opensearch_client.py
  • backend/cosmetology-app/lambdas/python/search/tests/function/test_manage_opensearch_indices.py
  • backend/cosmetology-app/lambdas/python/search/tests/function/test_populate_provider_documents.py
  • backend/cosmetology-app/lambdas/python/search/tests/function/test_provider_update_ingest.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • backend/cosmetology-app/lambdas/python/search/tests/function/test_populate_provider_documents.py
  • backend/cosmetology-app/lambdas/python/common/cc_common/data_model/provider_record_util.py

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (3)
backend/cosmetology-app/lambdas/python/common/cc_common/data_model/provider_record_util.py (1)

661-676: ⚡ Quick win

Hoist provider-level adverseActions out of the per-license loop.

self.get_adverse_action_records() and the dict conversion are evaluated on every iteration even though the value is identical for every document (it's a provider-level set). For providers with many licenses and/or many adverse actions this is gratuitous O(L·A) work, and it also diverges from the summary which states provider-level adverse actions are "precomputed and included as a top-level adverseActions array on each OpenSearch document."

♻️ Proposed fix
         provider_dict = self.get_provider_record().to_dict()
         all_privileges = self.generate_privileges_for_provider(include_inactive_privileges=True)

         # Determine the most recent (aka home) license for each license type
         most_recent_licenses = {
             (most_recent_license_for_type.jurisdiction.lower(), most_recent_license_for_type.licenseType)
             for most_recent_license_for_type in self.find_most_recent_licenses_for_each_license_type()
         }

+        provider_adverse_actions = [rec.to_dict() for rec in self.get_adverse_action_records()]
+
         documents = []
         for license_record in self.get_license_records():
@@
             doc = dict(provider_dict)
             doc['licenses'] = [license_dict]
             doc['privileges'] = license_privileges
-            doc['adverseActions'] = [rec.to_dict() for rec in self.get_adverse_action_records()]
+            # Assign a shallow copy so downstream mutations on one doc don't bleed into others.
+            doc['adverseActions'] = list(provider_adverse_actions)
             documents.append(doc)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@backend/cosmetology-app/lambdas/python/common/cc_common/data_model/provider_record_util.py`
around lines 661 - 676, The code currently calls
self.get_adverse_action_records() and converts its result to dict inside the
per-license loop, causing redundant O(L·A) work; move this call and the dict
conversion out of the loop so adverse actions are computed once at
provider-level and then reused for each license document. Specifically, in the
function that builds OpenSearch documents (where you iterate over provider
licenses — look for the for ... in provider.get("licenses", []) loop and the use
of self.get_adverse_action_records()), compute adverse_actions =
self.get_adverse_action_records(provider_id or provider_doc) and its dict form
before entering the loop, then remove the per-iteration calls and attach the
precomputed adverse_actions to each produced document as the top-level
adverseActions array.
backend/cosmetology-app/lambdas/python/search/tests/function/test_public_search_providers.py (2)

112-193: 💤 Low value

Consider aligning uploaded status fields with actual status parameters, or document the intentional mismatch.

The helper methods create potentially inconsistent test data:

  • Line 144: jurisdictionUploadedLicenseStatus is always 'active', even when license_status parameter is 'inactive'
  • Line 186: provider-level jurisdictionUploadedCompactEligibility is always 'eligible', even when the nested license may have jurisdiction_uploaded_compact_eligibility='ineligible'

If this mismatch is intentional (to test production correction/normalization logic), consider adding inline comments explaining why the "uploaded" fields differ from the computed fields. Otherwise, align these fields to reduce confusion.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@backend/cosmetology-app/lambdas/python/search/tests/function/test_public_search_providers.py`
around lines 112 - 193, The test helper is creating confusingly inconsistent
data: the variable jurisdictionUploadedLicenseStatus is hardcoded to 'active'
regardless of the license_status parameter, and provider-level
jurisdictionUploadedCompactEligibility is hardcoded to 'eligible' regardless of
nested license jurisdiction_uploaded_compact_eligibility; update the helper(s)
that set jurisdictionUploadedLicenseStatus and
jurisdictionUploadedCompactEligibility (search for those exact names or the
helper functions that construct provider/license fixtures) so the "uploaded"
fields are set to the corresponding input parameters (e.g., set
jurisdictionUploadedLicenseStatus = license_status and
jurisdictionUploadedCompactEligibility =
nested_license.jurisdiction_uploaded_compact_eligibility) OR, if the mismatch is
intentional, add concise inline comments at the assignments explaining the
intent and referencing the normalization code being tested.

695-727: ⚡ Quick win

Improve docstring clarity on intentional test data inconsistency.

The test correctly uses inconsistent data—license_status='active' with an expired date_of_expiration='2020-01-01'—to validate that the LicenseExpirationStatusMixin.correct_expired_license_status() @pre_load method properly corrects stale jurisdiction-uploaded statuses.

The docstring "inactive after schema correction" accurately describes this, but could be more explicit. Consider expanding it to: """Verify that the schema corrects a license with an expired date but active status (jurisdiction inconsistency) to ineligible.""" This makes the intentional test design clearer for readers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@backend/cosmetology-app/lambdas/python/search/tests/function/test_public_search_providers.py`
around lines 695 - 727, Update the test's docstring to explicitly state that the
test intentionally supplies inconsistent data (license_status='active' with
date_of_expiration='2020-01-01') to verify the
LicenseExpirationStatusMixin.correct_expired_license_status() `@pre_load` logic
converts a jurisdiction-uploaded "active" license with an expired date to
ineligible; for clarity, replace the existing short string with: "Verify that
the schema corrects a license with an expired date but active status
(jurisdiction inconsistency) to ineligible." Reference the test function in
test_public_search_providers.py where the docstring sits and ensure the new
docstring sits immediately above the test that exercises the mixin behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In
`@backend/cosmetology-app/lambdas/python/common/cc_common/data_model/provider_record_util.py`:
- Around line 661-676: The code currently calls
self.get_adverse_action_records() and converts its result to dict inside the
per-license loop, causing redundant O(L·A) work; move this call and the dict
conversion out of the loop so adverse actions are computed once at
provider-level and then reused for each license document. Specifically, in the
function that builds OpenSearch documents (where you iterate over provider
licenses — look for the for ... in provider.get("licenses", []) loop and the use
of self.get_adverse_action_records()), compute adverse_actions =
self.get_adverse_action_records(provider_id or provider_doc) and its dict form
before entering the loop, then remove the per-iteration calls and attach the
precomputed adverse_actions to each produced document as the top-level
adverseActions array.

In
`@backend/cosmetology-app/lambdas/python/search/tests/function/test_public_search_providers.py`:
- Around line 112-193: The test helper is creating confusingly inconsistent
data: the variable jurisdictionUploadedLicenseStatus is hardcoded to 'active'
regardless of the license_status parameter, and provider-level
jurisdictionUploadedCompactEligibility is hardcoded to 'eligible' regardless of
nested license jurisdiction_uploaded_compact_eligibility; update the helper(s)
that set jurisdictionUploadedLicenseStatus and
jurisdictionUploadedCompactEligibility (search for those exact names or the
helper functions that construct provider/license fixtures) so the "uploaded"
fields are set to the corresponding input parameters (e.g., set
jurisdictionUploadedLicenseStatus = license_status and
jurisdictionUploadedCompactEligibility =
nested_license.jurisdiction_uploaded_compact_eligibility) OR, if the mismatch is
intentional, add concise inline comments at the assignments explaining the
intent and referencing the normalization code being tested.
- Around line 695-727: Update the test's docstring to explicitly state that the
test intentionally supplies inconsistent data (license_status='active' with
date_of_expiration='2020-01-01') to verify the
LicenseExpirationStatusMixin.correct_expired_license_status() `@pre_load` logic
converts a jurisdiction-uploaded "active" license with an expired date to
ineligible; for clarity, replace the existing short string with: "Verify that
the schema corrects a license with an expired date but active status
(jurisdiction inconsistency) to ineligible." Reference the test function in
test_public_search_providers.py where the docstring sits and ensure the new
docstring sits immediately above the test that exercises the mixin behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7e9ff74c-6cc2-4407-8a57-e5014eaedb2e

📥 Commits

Reviewing files that changed from the base of the PR and between 8d3d4d3 and 5f0ff52.

📒 Files selected for processing (6)
  • backend/cosmetology-app/lambdas/python/common/cc_common/data_model/provider_record_util.py
  • backend/cosmetology-app/lambdas/python/provider-data-v1/handlers/__init__.py
  • backend/cosmetology-app/lambdas/python/provider-data-v1/handlers/public_lookup.py
  • backend/cosmetology-app/lambdas/python/provider-data-v1/tests/function/test_handlers/test_public_lookup.py
  • backend/cosmetology-app/lambdas/python/search/handlers/public_search.py
  • backend/cosmetology-app/lambdas/python/search/tests/function/test_public_search_providers.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • backend/cosmetology-app/lambdas/python/search/handlers/public_search.py

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.

in public search, licensees who are not eligible are marked "not eligible" in list view FE

1 participant