From 9115b917704ac8b36b99ca668b06eaf9ba705408 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Tue, 27 Jan 2026 16:25:58 -0600 Subject: [PATCH 01/18] Add email template for home state change --- .../email-notification-service/lambda.ts | 23 +++++++ .../lib/email/email-notification-service.ts | 52 ++++++++++++++ .../email/email-notification-service.test.ts | 68 +++++++++++++++++++ 3 files changed, 143 insertions(+) diff --git a/backend/compact-connect/lambdas/nodejs/email-notification-service/lambda.ts b/backend/compact-connect/lambdas/nodejs/email-notification-service/lambda.ts index 0d61c8718..e147de605 100644 --- a/backend/compact-connect/lambdas/nodejs/email-notification-service/lambda.ts +++ b/backend/compact-connect/lambdas/nodejs/email-notification-service/lambda.ts @@ -470,6 +470,29 @@ export class Lambda implements LambdaInterface { event.templateVariables.licenseType ); break; + case 'homeJurisdictionChangeOldStateNotification': + case 'homeJurisdictionChangeNewStateNotification': + if (!event.jurisdiction) { + throw new Error('Missing required jurisdiction field for home jurisdiction change notification template.'); + } + if (!event.templateVariables?.providerFirstName + || !event.templateVariables?.providerLastName + || !event.templateVariables?.providerId + || !event.templateVariables?.previousJurisdiction + || !event.templateVariables?.newJurisdiction) { + throw new Error('Missing required template variables for home jurisdiction change notification template.'); + } + // Both templates call the same method + await this.emailService.sendHomeJurisdictionChangeStateNotificationEmail( + event.compact, + event.jurisdiction, + event.templateVariables.providerFirstName, + event.templateVariables.providerLastName, + event.templateVariables.providerId, + event.templateVariables.previousJurisdiction, + event.templateVariables.newJurisdiction + ); + break; default: logger.info('Unsupported email template provided', { template: event.template }); throw new Error(`Unsupported email template: ${event.template}`); diff --git a/backend/compact-connect/lambdas/nodejs/lib/email/email-notification-service.ts b/backend/compact-connect/lambdas/nodejs/lib/email/email-notification-service.ts index 00edc16e1..80cdaa401 100644 --- a/backend/compact-connect/lambdas/nodejs/lib/email/email-notification-service.ts +++ b/backend/compact-connect/lambdas/nodejs/lib/email/email-notification-service.ts @@ -49,6 +49,8 @@ export class EmailNotificationService extends BaseEmailService { switch (recipientType) { case 'JURISDICTION_SUMMARY_REPORT': return jurisdictionConfig.jurisdictionSummaryReportNotificationEmails; + case 'JURISDICTION_OPERATIONS_TEAM': + return jurisdictionConfig.jurisdictionOperationsTeamEmails; default: throw new Error(`Unsupported recipient type for compact configuration: ${recipientType}`); } @@ -558,4 +560,54 @@ export class EmailNotificationService extends BaseEmailService { await this.sendEmail({ htmlContent, subject, recipients, errorMessage: 'Unable to send military audit declined notification email' }); } + + /** + * Sends a notification email to a jurisdiction operations team when a practitioner changes their home state + * @param compact - The compact name + * @param jurisdiction - The jurisdiction to notify + * @param providerFirstName - The provider's first name + * @param providerLastName - The provider's last name + * @param providerId - The provider's ID + * @param previousJurisdiction - The previous home jurisdiction + * @param newJurisdiction - The new home jurisdiction + */ + public async sendHomeJurisdictionChangeStateNotificationEmail( + compact: string, + jurisdiction: string, + providerFirstName: string, + providerLastName: string, + providerId: string, + previousJurisdiction: string, + newJurisdiction: string + ): Promise { + this.logger.info('Sending home jurisdiction change state notification email', { + compact: compact, + jurisdiction: jurisdiction + }); + + const recipients = await this.getJurisdictionRecipients( + compact, + jurisdiction, + 'JURISDICTION_OPERATIONS_TEAM' + ); + + if (recipients.length === 0) { + throw new Error(`No recipients found for jurisdiction ${jurisdiction} in compact ${compact}`); + } + + const compactConfig = await this.compactConfigurationClient.getCompactConfiguration(compact); + const report = this.getNewEmailTemplate(); + const subject = `Practitioner Home State Change - ${compactConfig.compactName}`; + const bodyText = `This is to notify you that ${providerFirstName} ${providerLastName} has changed their home state from ${previousJurisdiction} to ${newJurisdiction}.\n\n` + + `Provider Details: ${environmentVariableService.getUiBasePathUrl()}/${compact}/Licensing/${providerId}\n\n` + + 'If the above link does not work, you can copy and paste the url into a browser tab, where you are already logged in.'; + + this.insertHeader(report, subject); + this.insertBody(report, bodyText, 'center', true); + this.insertFooter(report); + + const htmlContent = this.renderTemplate(report); + + await this.sendEmail({ htmlContent, subject, recipients, errorMessage: 'Unable to send home jurisdiction change state notification email' }); + } } diff --git a/backend/compact-connect/lambdas/nodejs/tests/lib/email/email-notification-service.test.ts b/backend/compact-connect/lambdas/nodejs/tests/lib/email/email-notification-service.test.ts index 2befda98e..b7f0b354c 100644 --- a/backend/compact-connect/lambdas/nodejs/tests/lib/email/email-notification-service.test.ts +++ b/backend/compact-connect/lambdas/nodejs/tests/lib/email/email-notification-service.test.ts @@ -955,4 +955,72 @@ describe('EmailNotificationService', () => { expect(htmlContent).toContain(expectedResetUrl); }); }); + + describe('Home Jurisdiction Change State Notification', () => { + it('should send home jurisdiction change state notification email with expected content', async () => { + mockCompactConfigurationClient.getCompactConfiguration.mockResolvedValue(SAMPLE_COMPACT_CONFIG); + mockJurisdictionClient.getJurisdictionConfiguration.mockResolvedValue(SAMPLE_JURISDICTION_CONFIG); + + await emailService.sendHomeJurisdictionChangeStateNotificationEmail( + 'aslp', + 'oh', + 'John', + 'Doe', + 'provider-123', + 'TX', + 'OH' + ); + + expect(mockJurisdictionClient.getJurisdictionConfiguration).toHaveBeenCalledWith('aslp', 'oh'); + + expect(mockSESClient).toHaveReceivedCommandWith( + SendEmailCommand, + { + Destination: { + ToAddresses: ['oh-ops@example.com'] + }, + Content: { + Simple: { + Body: { + Html: { + Charset: 'UTF-8', + Data: expect.stringContaining('') + } + }, + Subject: { + Charset: 'UTF-8', + Data: 'Practitioner Home State Change - Audiology and Speech Language Pathology' + } + } + }, + FromEmailAddress: 'Compact Connect ' + } + ); + + // Get the actual HTML content for detailed validation + const emailCall = mockSESClient.commandCalls(SendEmailCommand)[0]; + const htmlContent = emailCall.args[0].input.Content?.Simple?.Body?.Html?.Data; + + expect(htmlContent).toBeDefined(); + expect(htmlContent).toContain('This is to notify you that John Doe has changed their home state from TX to OH.'); + expect(htmlContent).toContain('https://app.test.compactconnect.org/aslp/Licensing/provider-123'); + }); + + it('should throw error when no recipients found for jurisdiction', async () => { + mockJurisdictionClient.getJurisdictionConfiguration.mockResolvedValue({ + ...SAMPLE_JURISDICTION_CONFIG, + jurisdictionOperationsTeamEmails: [] + }); + + await expect(emailService.sendHomeJurisdictionChangeStateNotificationEmail( + 'aslp', + 'oh', + 'John', + 'Doe', + 'provider-123', + 'TX', + 'OH' + )).rejects.toThrow('No recipients found for jurisdiction oh in compact aslp'); + }); + }); }); From 71735674a47342ce4ad8366a5c579b50d32dcb65 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Tue, 27 Jan 2026 17:05:56 -0600 Subject: [PATCH 02/18] Add listener for provider home state changes --- .../compact_configuration_client.py | 35 ++ .../data_model/schema/data_event/api.py | 10 + .../common/cc_common/email_service_client.py | 72 ++++ .../common/cc_common/event_state_client.py | 1 + .../handlers/home_jurisdiction_events.py | 194 ++++++++++ .../data-events/tests/function/__init__.py | 17 + .../function/test_home_jurisdiction_events.py | 352 ++++++++++++++++++ .../stacks/notification_stack.py | 18 + 8 files changed, 699 insertions(+) create mode 100644 backend/compact-connect/lambdas/python/data-events/handlers/home_jurisdiction_events.py create mode 100644 backend/compact-connect/lambdas/python/data-events/tests/function/test_home_jurisdiction_events.py diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/compact_configuration_client.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/compact_configuration_client.py index a31b15097..37b042c18 100644 --- a/backend/compact-connect/lambdas/python/common/cc_common/data_model/compact_configuration_client.py +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/compact_configuration_client.py @@ -238,6 +238,41 @@ def get_jurisdiction_configuration(self, compact: str, jurisdiction: str) -> Jur # Load through schema and convert to Jurisdiction model return JurisdictionConfigurationData.from_database_record(item) + def get_jurisdiction_operations_team_emails(self, compact: str, jurisdiction: str) -> list[str] | None: + """ + Get the operations team email addresses for a specific jurisdiction within a compact. + + Returns None if the jurisdiction configuration is not found. + Returns an empty list if the configuration exists but no operations team emails are configured. + Returns a list of email addresses if operations team emails are configured. + + :param compact: The compact abbreviation + :param jurisdiction: The jurisdiction postal abbreviation + :return: List of operations team email addresses, empty list if none configured, or None if jurisdiction not found + """ + logger.info('Getting jurisdiction operations team emails', compact=compact, jurisdiction=jurisdiction) + + try: + jurisdiction_config = self.get_jurisdiction_configuration(compact=compact, jurisdiction=jurisdiction) + operations_emails = jurisdiction_config.jurisdictionOperationsTeamEmails + + if not operations_emails: + logger.info( + 'No operations team emails configured for jurisdiction', + compact=compact, + jurisdiction=jurisdiction, + ) + return [] + + return operations_emails + except CCNotFoundException: + logger.info( + 'Jurisdiction configuration not found', + compact=compact, + jurisdiction=jurisdiction, + ) + return None + def save_jurisdiction_configuration(self, jurisdiction_config: JurisdictionConfigurationData) -> None: """ Save the jurisdiction configuration and update related compact configuration if needed. diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/data_event/api.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/data_event/api.py index d761459fa..0b98d67e3 100644 --- a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/data_event/api.py +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/data_event/api.py @@ -97,3 +97,13 @@ class MilitaryAuditEventDetailSchema(ForgivingSchema): ) auditNote = String(required=False, allow_none=False) eventTime = DateTime(required=True, allow_none=False) + + +class HomeJurisdictionChangeEventDetailSchema(ForgivingSchema): + """Schema for home jurisdiction change events""" + + compact = Compact(required=True, allow_none=False) + providerId = UUID(required=True, allow_none=False) + previousHomeJurisdiction = String(required=False, allow_none=True) + newHomeJurisdiction = String(required=True, allow_none=False) + eventTime = DateTime(required=True, allow_none=False) diff --git a/backend/compact-connect/lambdas/python/common/cc_common/email_service_client.py b/backend/compact-connect/lambdas/python/common/cc_common/email_service_client.py index d655c06b3..0aea109b9 100644 --- a/backend/compact-connect/lambdas/python/common/cc_common/email_service_client.py +++ b/backend/compact-connect/lambdas/python/common/cc_common/email_service_client.py @@ -808,3 +808,75 @@ def send_military_audit_declined_notification( }, } return self._invoke_lambda(payload) + + def send_home_jurisdiction_change_old_state_notification( + self, + *, + compact: str, + jurisdiction: str, + provider_first_name: str, + provider_last_name: str, + provider_id: UUID, + new_jurisdiction: str, + ) -> dict[str, str]: + """ + Notify the old home state that a practitioner has changed their home jurisdiction. + + :param compact: Compact name + :param jurisdiction: Old jurisdiction to notify + :param provider_first_name: Provider's first name + :param provider_last_name: Provider's last name + :param provider_id: Provider ID + :param new_jurisdiction: New home jurisdiction + :return: Response from the email notification service + """ + payload = { + 'compact': compact, + 'jurisdiction': jurisdiction, + 'template': 'homeJurisdictionChangeOldStateNotification', + 'recipientType': 'JURISDICTION_OPERATIONS_TEAM', + 'templateVariables': { + 'providerFirstName': provider_first_name, + 'providerLastName': provider_last_name, + 'providerId': str(provider_id), + 'previousJurisdiction': jurisdiction.upper(), + 'newJurisdiction': new_jurisdiction.upper(), + }, + } + return self._invoke_lambda(payload) + + def send_home_jurisdiction_change_new_state_notification( + self, + *, + compact: str, + jurisdiction: str, + provider_first_name: str, + provider_last_name: str, + provider_id: UUID, + previous_jurisdiction: str | None, + ) -> dict[str, str]: + """ + Notify the new home state that a practitioner has selected them as their home jurisdiction. + + :param compact: Compact name + :param jurisdiction: New jurisdiction to notify + :param provider_first_name: Provider's first name + :param provider_last_name: Provider's last name + :param provider_id: Provider ID + :param previous_jurisdiction: Previous home jurisdiction (can be None) + :return: Response from the email notification service + """ + payload = { + 'compact': compact, + 'jurisdiction': jurisdiction, + 'template': 'homeJurisdictionChangeNewStateNotification', + 'recipientType': 'JURISDICTION_OPERATIONS_TEAM', + 'templateVariables': { + 'providerFirstName': provider_first_name, + 'providerLastName': provider_last_name, + 'providerId': str(provider_id), + 'previousJurisdiction': previous_jurisdiction.upper() if previous_jurisdiction else 'Unlisted Jurisdiction', + 'newJurisdiction': jurisdiction.upper(), + }, + } + return self._invoke_lambda(payload) diff --git a/backend/compact-connect/lambdas/python/common/cc_common/event_state_client.py b/backend/compact-connect/lambdas/python/common/cc_common/event_state_client.py index 6849357b9..b4fad1a56 100644 --- a/backend/compact-connect/lambdas/python/common/cc_common/event_state_client.py +++ b/backend/compact-connect/lambdas/python/common/cc_common/event_state_client.py @@ -29,6 +29,7 @@ class EventType(StrEnum): PRIVILEGE_ENCUMBRANCE_LIFTED = 'privilege.encumbranceLifted' MILITARY_AUDIT_APPROVED = 'militaryAffiliation.auditApproved' MILITARY_AUDIT_DECLINED = 'militaryAffiliation.auditDeclined' + HOME_JURISDICTION_CHANGE = 'provider.homeJurisdictionChange' class EventStateClient: diff --git a/backend/compact-connect/lambdas/python/data-events/handlers/home_jurisdiction_events.py b/backend/compact-connect/lambdas/python/data-events/handlers/home_jurisdiction_events.py new file mode 100644 index 000000000..42841985c --- /dev/null +++ b/backend/compact-connect/lambdas/python/data-events/handlers/home_jurisdiction_events.py @@ -0,0 +1,194 @@ +from uuid import UUID + +from cc_common.config import config, logger +from cc_common.data_model.schema.data_event.api import HomeJurisdictionChangeEventDetailSchema +from cc_common.data_model.schema.fields import OTHER_JURISDICTION +from cc_common.event_state_client import EventType, NotificationTracker, RecipientType +from cc_common.exceptions import CCInternalException +from cc_common.utils import sqs_handler_with_notification_tracking + + +@sqs_handler_with_notification_tracking +def home_jurisdiction_change_notification_listener(message: dict, tracker: NotificationTracker): + """ + Handle home jurisdiction change events by sending notifications to affected states. + + Notification rules: + - Notify the old home state if it's a compact member state + - Notify the new home state if it's a compact member state + + :param message: The SQS message containing the EventBridge event + :param tracker: NotificationTracker for idempotency + """ + logger.info('Processing home jurisdiction change notification event') + + # Validate and extract event detail + detail = message.get('detail', {}) + schema = HomeJurisdictionChangeEventDetailSchema() + validated_detail = schema.load(detail) + + compact = validated_detail['compact'] + provider_id = validated_detail['providerId'] + previous_jurisdiction = validated_detail['previousHomeJurisdiction'] + new_jurisdiction = validated_detail['newHomeJurisdiction'] + event_time = validated_detail['eventTime'].isoformat() + + with logger.append_context_keys( + compact=compact, + provider_id=str(provider_id), + previous_jurisdiction=previous_jurisdiction, + new_jurisdiction=new_jurisdiction, + ): + logger.info('Processing home jurisdiction change notification') + + # Get provider information for email template + try: + provider_record = config.data_client.get_provider_top_level_record( + compact=compact, provider_id=str(provider_id) + ) + except Exception as e: + logger.error('Failed to retrieve provider record for notification', exception=str(e)) + raise + + # Notify old home state if it exists and has operations team emails configured + if previous_jurisdiction and previous_jurisdiction.lower() != OTHER_JURISDICTION: + _send_old_state_notification( + compact=compact, + provider_id=provider_id, + provider_record=provider_record, + previous_jurisdiction=previous_jurisdiction, + new_jurisdiction=new_jurisdiction, + event_time=event_time, + tracker=tracker, + ) + + # Notify new home state if it has operations team emails configured + if new_jurisdiction.lower() != OTHER_JURISDICTION: + _send_new_state_notification( + compact=compact, + provider_id=provider_id, + provider_record=provider_record, + previous_jurisdiction=previous_jurisdiction, + new_jurisdiction=new_jurisdiction, + event_time=event_time, + tracker=tracker, + ) + + logger.info('Successfully processed home jurisdiction change notification event') + + +def _send_old_state_notification( + *, + compact: str, + provider_id: UUID, + provider_record, + previous_jurisdiction: str, + new_jurisdiction: str, + event_time: str, + tracker: NotificationTracker, +): + """Send notification to the old home state if it has operations team emails configured.""" + # Check if jurisdiction has operations team emails configured + operations_emails = config.compact_configuration_client.get_jurisdiction_operations_team_emails( + compact=compact, jurisdiction=previous_jurisdiction + ) + + if not operations_emails: + logger.info( + 'Skipping old state notification - no operations team emails configured', + compact=compact, + jurisdiction=previous_jurisdiction, + provider_id=provider_id, + ) + return + + if tracker.should_send_state_notification(previous_jurisdiction): + logger.info('Sending home jurisdiction change notification to old state', + compact=compact, provider_id=provider_id, jurisdiction=previous_jurisdiction) + try: + config.email_service_client.send_home_jurisdiction_change_old_state_notification( + compact=compact, + jurisdiction=previous_jurisdiction, + provider_first_name=provider_record.givenName, + provider_last_name=provider_record.familyName, + provider_id=provider_id, + new_jurisdiction=new_jurisdiction, + ) + tracker.record_success( + recipient_type=RecipientType.STATE, + provider_id=provider_id, + event_type=EventType.HOME_JURISDICTION_CHANGE, + event_time=event_time, + jurisdiction=previous_jurisdiction, + ) + except Exception as e: + logger.error('Failed to send old state notification', exception=str(e)) + tracker.record_failure( + recipient_type=RecipientType.STATE, + provider_id=provider_id, + event_type=EventType.HOME_JURISDICTION_CHANGE, + event_time=event_time, + error_message=str(e), + jurisdiction=previous_jurisdiction, + ) + raise + else: + logger.info('Skipping old state notification (already sent)', jurisdiction=previous_jurisdiction) + + +def _send_new_state_notification( + *, + compact: str, + provider_id: UUID, + provider_record, + previous_jurisdiction: str | None, + new_jurisdiction: str, + event_time: str, + tracker: NotificationTracker, +): + """Send notification to the new home state if it has operations team emails configured.""" + # Check if jurisdiction has operations team emails configured + operations_emails = config.compact_configuration_client.get_jurisdiction_operations_team_emails( + compact=compact, jurisdiction=new_jurisdiction + ) + + if not operations_emails: + logger.info( + 'Skipping new state notification - no operations team emails configured', + compact=compact, + jurisdiction=new_jurisdiction, + provider_id=provider_id, + ) + return + + if tracker.should_send_state_notification(new_jurisdiction): + logger.info('Sending home jurisdiction change notification to new state', jurisdiction=new_jurisdiction) + try: + config.email_service_client.send_home_jurisdiction_change_new_state_notification( + compact=compact, + jurisdiction=new_jurisdiction, + provider_first_name=provider_record.givenName, + provider_last_name=provider_record.familyName, + provider_id=provider_id, + previous_jurisdiction=previous_jurisdiction, + ) + tracker.record_success( + recipient_type=RecipientType.STATE, + provider_id=provider_id, + event_type=EventType.HOME_JURISDICTION_CHANGE, + event_time=event_time, + jurisdiction=new_jurisdiction, + ) + except Exception as e: + logger.error('Failed to send new state notification', exception=str(e)) + tracker.record_failure( + recipient_type=RecipientType.STATE, + provider_id=provider_id, + event_type=EventType.HOME_JURISDICTION_CHANGE, + event_time=event_time, + error_message=str(e), + jurisdiction=new_jurisdiction, + ) + raise + else: + logger.info('Skipping new state notification (already sent)', jurisdiction=new_jurisdiction) diff --git a/backend/compact-connect/lambdas/python/data-events/tests/function/__init__.py b/backend/compact-connect/lambdas/python/data-events/tests/function/__init__.py index 5e54641c2..85ea6dda0 100644 --- a/backend/compact-connect/lambdas/python/data-events/tests/function/__init__.py +++ b/backend/compact-connect/lambdas/python/data-events/tests/function/__init__.py @@ -33,6 +33,7 @@ def build_resources(self): self.create_rate_limit_table() self.create_event_state_table() self.create_provider_table() + self.create_compact_configuration_table() def create_data_event_table(self): self._data_event_table = boto3.resource('dynamodb').create_table( @@ -120,8 +121,24 @@ def create_provider_table(self): ], ) + def create_compact_configuration_table(self): + """Create the compact configuration table for testing.""" + self._compact_configuration_table = boto3.resource('dynamodb').create_table( + AttributeDefinitions=[ + {'AttributeName': 'pk', 'AttributeType': 'S'}, + {'AttributeName': 'sk', 'AttributeType': 'S'}, + ], + TableName=os.environ['COMPACT_CONFIGURATION_TABLE_NAME'], + KeySchema=[ + {'AttributeName': 'pk', 'KeyType': 'HASH'}, + {'AttributeName': 'sk', 'KeyType': 'RANGE'}, + ], + BillingMode='PAY_PER_REQUEST', + ) + def delete_resources(self): self._data_event_table.delete() self._rate_limit_table.delete() self._event_state_table.delete() self._provider_table.delete() + self._compact_configuration_table.delete() diff --git a/backend/compact-connect/lambdas/python/data-events/tests/function/test_home_jurisdiction_events.py b/backend/compact-connect/lambdas/python/data-events/tests/function/test_home_jurisdiction_events.py new file mode 100644 index 000000000..6c91d1c4a --- /dev/null +++ b/backend/compact-connect/lambdas/python/data-events/tests/function/test_home_jurisdiction_events.py @@ -0,0 +1,352 @@ +import json +import uuid +from datetime import datetime +from unittest.mock import patch + +from common_test.test_constants import ( + DEFAULT_COMPACT, + DEFAULT_DATE_OF_UPDATE_TIMESTAMP, + DEFAULT_PROVIDER_ID, +) +from moto import mock_aws + +from . import TstFunction + + +@mock_aws +@patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat(DEFAULT_DATE_OF_UPDATE_TIMESTAMP)) +class TestHomeJurisdictionChangeEvents(TstFunction): + """Test suite for home jurisdiction change event handlers.""" + + def _generate_home_jurisdiction_change_message( + self, previous_jurisdiction: str | None, new_jurisdiction: str + ): + """Generate a test EventBridge message for home jurisdiction change events.""" + message = { + 'detail': { + 'compact': DEFAULT_COMPACT, + 'providerId': DEFAULT_PROVIDER_ID, + 'previousHomeJurisdiction': previous_jurisdiction, + 'newHomeJurisdiction': new_jurisdiction, + 'eventTime': DEFAULT_DATE_OF_UPDATE_TIMESTAMP, + } + } + return message + + def _create_sqs_event(self, message): + """Create a proper SQS event structure with the message in the body.""" + return {'Records': [{'messageId': str(uuid.uuid4()), 'body': json.dumps(message)}]} + + @patch('cc_common.email_service_client.EmailServiceClient.send_home_jurisdiction_change_old_state_notification') + @patch('cc_common.email_service_client.EmailServiceClient.send_home_jurisdiction_change_new_state_notification') + def test_both_states_notified_when_changing_to_compact_state( + self, mock_send_new_state, mock_send_old_state + ): + """Test that both old and new states are notified when changing to another compact state.""" + from handlers.home_jurisdiction_events import home_jurisdiction_change_notification_listener + + # Set up test data + self.test_data_generator.put_default_provider_record_in_provider_table( + value_overrides={ + 'givenName': 'John', + 'familyName': 'Doe', + 'currentHomeJurisdiction': 'oh', + } + ) + + # Set up jurisdiction configurations for both states + self.test_data_generator.put_default_jurisdiction_configuration_in_configuration_table( + value_overrides={ + 'compact': DEFAULT_COMPACT, + 'postalAbbreviation': 'oh', + 'jurisdictionName': 'Ohio', + 'jurisdictionOperationsTeamEmails': ['oh-ops@example.com'], + } + ) + self.test_data_generator.put_default_jurisdiction_configuration_in_configuration_table( + value_overrides={ + 'compact': DEFAULT_COMPACT, + 'postalAbbreviation': 'tx', + 'jurisdictionName': 'Texas', + 'jurisdictionOperationsTeamEmails': ['tx-ops@example.com'], + } + ) + + message = self._generate_home_jurisdiction_change_message('oh', 'tx') + event = self._create_sqs_event(message) + + # Execute the handler + result = home_jurisdiction_change_notification_listener(event, self.mock_context) + + # Should succeed with no batch failures + self.assertEqual({'batchItemFailures': []}, result) + + # Verify both emails were sent + mock_send_old_state.assert_called_once() + mock_send_new_state.assert_called_once() + + # Verify old state notification details + old_state_call = mock_send_old_state.call_args + self.assertEqual(DEFAULT_COMPACT, old_state_call.kwargs['compact']) + self.assertEqual('oh', old_state_call.kwargs['jurisdiction']) + self.assertEqual('John', old_state_call.kwargs['provider_first_name']) + self.assertEqual('Doe', old_state_call.kwargs['provider_last_name']) + self.assertEqual('tx', old_state_call.kwargs['new_jurisdiction']) + + # Verify new state notification details + new_state_call = mock_send_new_state.call_args + self.assertEqual(DEFAULT_COMPACT, new_state_call.kwargs['compact']) + self.assertEqual('tx', new_state_call.kwargs['jurisdiction']) + self.assertEqual('John', new_state_call.kwargs['provider_first_name']) + self.assertEqual('Doe', new_state_call.kwargs['provider_last_name']) + self.assertEqual('oh', new_state_call.kwargs['previous_jurisdiction']) + + @patch('cc_common.email_service_client.EmailServiceClient.send_home_jurisdiction_change_old_state_notification') + @patch('cc_common.email_service_client.EmailServiceClient.send_home_jurisdiction_change_new_state_notification') + def test_only_old_state_notified_when_changing_to_other(self, mock_send_new_state, mock_send_old_state): + """Test that only old state is notified when changing to 'other' (non-compact state).""" + from handlers.home_jurisdiction_events import home_jurisdiction_change_notification_listener + + # Set up test data + self.test_data_generator.put_default_provider_record_in_provider_table( + value_overrides={ + 'givenName': 'Jane', + 'familyName': 'Smith', + 'currentHomeJurisdiction': 'oh', + } + ) + + # Set up jurisdiction configuration for old state only + self.test_data_generator.put_default_jurisdiction_configuration_in_configuration_table( + value_overrides={ + 'compact': DEFAULT_COMPACT, + 'postalAbbreviation': 'oh', + 'jurisdictionName': 'Ohio', + 'jurisdictionOperationsTeamEmails': ['oh-ops@example.com'], + } + ) + + message = self._generate_home_jurisdiction_change_message('oh', 'other') + event = self._create_sqs_event(message) + + # Execute the handler + result = home_jurisdiction_change_notification_listener(event, self.mock_context) + + # Should succeed with no batch failures + self.assertEqual({'batchItemFailures': []}, result) + + # Verify only old state was notified + mock_send_old_state.assert_called_once() + mock_send_new_state.assert_not_called() + + @patch('cc_common.email_service_client.EmailServiceClient.send_home_jurisdiction_change_old_state_notification') + @patch('cc_common.email_service_client.EmailServiceClient.send_home_jurisdiction_change_new_state_notification') + def test_no_old_state_notification_when_previous_is_other(self, mock_send_new_state, mock_send_old_state): + """Test that old state is not notified when previous jurisdiction is 'other'.""" + from handlers.home_jurisdiction_events import home_jurisdiction_change_notification_listener + from cc_common.data_model.schema.fields import OTHER_JURISDICTION + + # Set up test data + self.test_data_generator.put_default_provider_record_in_provider_table( + value_overrides={ + 'givenName': 'Bob', + 'familyName': 'Johnson', + 'currentHomeJurisdiction': OTHER_JURISDICTION, + } + ) + + # Set up jurisdiction configuration for new state only + self.test_data_generator.put_default_jurisdiction_configuration_in_configuration_table( + value_overrides={ + 'compact': DEFAULT_COMPACT, + 'postalAbbreviation': 'tx', + 'jurisdictionName': 'Texas', + 'jurisdictionOperationsTeamEmails': ['tx-ops@example.com'], + } + ) + + message = self._generate_home_jurisdiction_change_message(None, 'tx') + event = self._create_sqs_event(message) + + # Execute the handler + result = home_jurisdiction_change_notification_listener(event, self.mock_context) + + # Should succeed with no batch failures + self.assertEqual({'batchItemFailures': []}, result) + + # Verify only new state was notified + mock_send_old_state.assert_not_called() + mock_send_new_state.assert_called_once() + + @patch('cc_common.email_service_client.EmailServiceClient.send_home_jurisdiction_change_old_state_notification') + @patch('cc_common.email_service_client.EmailServiceClient.send_home_jurisdiction_change_new_state_notification') + def test_idempotency_prevents_duplicate_notifications(self, mock_send_new_state, mock_send_old_state): + """Test that NotificationTracker prevents duplicate notifications on retries.""" + from handlers.home_jurisdiction_events import home_jurisdiction_change_notification_listener + + # Set up test data + self.test_data_generator.put_default_provider_record_in_provider_table( + value_overrides={ + 'givenName': 'Test', + 'familyName': 'User', + 'currentHomeJurisdiction': 'oh', + } + ) + + # Set up jurisdiction configurations for both states + self.test_data_generator.put_default_jurisdiction_configuration_in_configuration_table( + value_overrides={ + 'compact': DEFAULT_COMPACT, + 'postalAbbreviation': 'oh', + 'jurisdictionName': 'Ohio', + 'jurisdictionOperationsTeamEmails': ['oh-ops@example.com'], + } + ) + self.test_data_generator.put_default_jurisdiction_configuration_in_configuration_table( + value_overrides={ + 'compact': DEFAULT_COMPACT, + 'postalAbbreviation': 'tx', + 'jurisdictionName': 'Texas', + 'jurisdictionOperationsTeamEmails': ['tx-ops@example.com'], + } + ) + + message = self._generate_home_jurisdiction_change_message('oh', 'tx') + event = self._create_sqs_event(message) + + # First execution - should send notification + result1 = home_jurisdiction_change_notification_listener(event, self.mock_context) + self.assertEqual({'batchItemFailures': []}, result1) + self.assertEqual(1, mock_send_old_state.call_count) + self.assertEqual(1, mock_send_new_state.call_count) + + # Second execution with same message ID - should skip (idempotency) + result2 = home_jurisdiction_change_notification_listener(event, self.mock_context) + self.assertEqual({'batchItemFailures': []}, result2) + # Should still be 1, not 2 + self.assertEqual(1, mock_send_old_state.call_count) + self.assertEqual(1, mock_send_new_state.call_count) + + @patch('cc_common.email_service_client.EmailServiceClient.send_home_jurisdiction_change_old_state_notification') + def test_error_handling_records_failure(self, mock_send_old_state): + """Test that email failures are recorded properly and cause batch item failure.""" + from handlers.home_jurisdiction_events import home_jurisdiction_change_notification_listener + + # Set up test data + self.test_data_generator.put_default_provider_record_in_provider_table( + value_overrides={ + 'givenName': 'Error', + 'familyName': 'Test', + 'currentHomeJurisdiction': 'oh', + } + ) + + # Set up jurisdiction configurations for both states + self.test_data_generator.put_default_jurisdiction_configuration_in_configuration_table( + value_overrides={ + 'compact': DEFAULT_COMPACT, + 'postalAbbreviation': 'oh', + 'jurisdictionName': 'Ohio', + 'jurisdictionOperationsTeamEmails': ['oh-ops@example.com'], + } + ) + self.test_data_generator.put_default_jurisdiction_configuration_in_configuration_table( + value_overrides={ + 'compact': DEFAULT_COMPACT, + 'postalAbbreviation': 'tx', + 'jurisdictionName': 'Texas', + 'jurisdictionOperationsTeamEmails': ['tx-ops@example.com'], + } + ) + + # Simulate email send failure + mock_send_old_state.side_effect = Exception('Email service unavailable') + + message = self._generate_home_jurisdiction_change_message('oh', 'tx') + event = self._create_sqs_event(message) + + # Execute the handler - should return item failure + result = home_jurisdiction_change_notification_listener(event, self.mock_context) + + # Should have one batch item failure + self.assertEqual(1, len(result['batchItemFailures'])) + + @patch('cc_common.email_service_client.EmailServiceClient.send_home_jurisdiction_change_old_state_notification') + @patch('cc_common.email_service_client.EmailServiceClient.send_home_jurisdiction_change_new_state_notification') + def test_no_notification_when_jurisdiction_has_no_operations_emails( + self, mock_send_new_state, mock_send_old_state + ): + """Test that notifications are skipped when jurisdiction has no operations team emails.""" + from handlers.home_jurisdiction_events import home_jurisdiction_change_notification_listener + + # Set up test data + self.test_data_generator.put_default_provider_record_in_provider_table( + value_overrides={ + 'givenName': 'No', + 'familyName': 'Emails', + 'currentHomeJurisdiction': 'oh', + } + ) + + # Set up jurisdiction configurations with no operations emails + self.test_data_generator.put_default_jurisdiction_configuration_in_configuration_table( + value_overrides={ + 'compact': DEFAULT_COMPACT, + 'postalAbbreviation': 'oh', + 'jurisdictionName': 'Ohio', + 'jurisdictionOperationsTeamEmails': [], + } + ) + self.test_data_generator.put_default_jurisdiction_configuration_in_configuration_table( + value_overrides={ + 'compact': DEFAULT_COMPACT, + 'postalAbbreviation': 'tx', + 'jurisdictionName': 'Texas', + 'jurisdictionOperationsTeamEmails': [], + } + ) + + message = self._generate_home_jurisdiction_change_message('oh', 'tx') + event = self._create_sqs_event(message) + + # Execute the handler + result = home_jurisdiction_change_notification_listener(event, self.mock_context) + + # Should succeed with no batch failures + self.assertEqual({'batchItemFailures': []}, result) + + # Verify no emails were sent + mock_send_old_state.assert_not_called() + mock_send_new_state.assert_not_called() + + @patch('cc_common.email_service_client.EmailServiceClient.send_home_jurisdiction_change_old_state_notification') + @patch('cc_common.email_service_client.EmailServiceClient.send_home_jurisdiction_change_new_state_notification') + def test_no_notification_when_jurisdiction_config_not_found( + self, mock_send_new_state, mock_send_old_state + ): + """Test that notifications are skipped when jurisdiction configuration is not found.""" + from handlers.home_jurisdiction_events import home_jurisdiction_change_notification_listener + + # Set up test data + self.test_data_generator.put_default_provider_record_in_provider_table( + value_overrides={ + 'givenName': 'No', + 'familyName': 'Config', + 'currentHomeJurisdiction': 'oh', + } + ) + + # Don't set up any jurisdiction configurations - they won't be found + + message = self._generate_home_jurisdiction_change_message('oh', 'tx') + event = self._create_sqs_event(message) + + # Execute the handler + result = home_jurisdiction_change_notification_listener(event, self.mock_context) + + # Should succeed with no batch failures + self.assertEqual({'batchItemFailures': []}, result) + + # Verify no emails were sent + mock_send_old_state.assert_not_called() + mock_send_new_state.assert_not_called() diff --git a/backend/compact-connect/stacks/notification_stack.py b/backend/compact-connect/stacks/notification_stack.py index f5266a9d0..13dbfd780 100644 --- a/backend/compact-connect/stacks/notification_stack.py +++ b/backend/compact-connect/stacks/notification_stack.py @@ -70,6 +70,9 @@ def __init__( self._add_military_audit_notification_listener( persistent_stack=persistent_stack, data_event_bus=data_event_bus, event_state_stack=event_state_stack ) + self._add_home_jurisdiction_change_notification_listener( + persistent_stack=persistent_stack, data_event_bus=data_event_bus, event_state_stack=event_state_stack + ) def _add_privilege_purchase_notification_chain( self, persistent_stack: ps.PersistentStack, data_event_bus: IEventBus @@ -207,6 +210,7 @@ def _add_emailer_event_listener( # Grant necessary permissions persistent_stack.provider_table.grant_read_data(emailer_event_listener_handler) + persistent_stack.compact_configuration_table.grant_read_data(emailer_event_listener_handler) persistent_stack.email_notification_service_lambda.grant_invoke(emailer_event_listener_handler) event_state_stack.event_state_table.grant_read_write_data(emailer_event_listener_handler) @@ -360,3 +364,17 @@ def _add_military_audit_notification_listener( data_event_bus=data_event_bus, event_state_stack=event_state_stack, ) + + def _add_home_jurisdiction_change_notification_listener( + self, persistent_stack: ps.PersistentStack, data_event_bus: EventBus, event_state_stack: ess.EventStateStack + ): + """Add the home jurisdiction change notification listener lambda, queues, and event rules.""" + self._add_emailer_event_listener( + construct_id_prefix='HomeJurisdictionChangeNotificationListener', + index='home_jurisdiction_events.py', + handler='home_jurisdiction_change_notification_listener', + listener_detail_type='provider.homeJurisdictionChange', + persistent_stack=persistent_stack, + data_event_bus=data_event_bus, + event_state_stack=event_state_stack, + ) From 591ffc462b4d0182d76a9fd2c0c949b4b4730b10 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Wed, 28 Jan 2026 09:35:27 -0600 Subject: [PATCH 03/18] publish home state change event --- .../cc_common/data_model/data_client.py | 14 +++++-- .../common/cc_common/event_bus_client.py | 36 ++++++++++++++++ .../handlers/provider_users.py | 12 +++++- ...provider_users_home_jurisdiction_change.py | 41 +++++++++++++++++++ 4 files changed, 98 insertions(+), 5 deletions(-) diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/data_client.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/data_client.py index b250176b2..cd401fc44 100644 --- a/backend/compact-connect/lambdas/python/common/cc_common/data_model/data_client.py +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/data_client.py @@ -2559,7 +2559,7 @@ def _process_jurisdiction_change_deactivation( @logger_inject_kwargs(logger, 'compact', 'provider_id', 'selected_jurisdiction') def update_provider_home_state_jurisdiction( self, *, compact: str, provider_id: str, selected_jurisdiction: str - ) -> None: + ) -> str | None: """ Update the provider's home jurisdiction and handle their privileges according to business rules. @@ -2584,6 +2584,7 @@ def update_provider_home_state_jurisdiction( :param compact: The compact name :param provider_id: The provider ID :param selected_jurisdiction: The new home jurisdiction selected by the provider + :return: The previous home jurisdiction (before the update), or None if there was no previous home jurisdiction :raises CCInternalException: If any transaction fails during the update process """ logger.info('Updating provider user home jurisdiction') @@ -2592,7 +2593,9 @@ def update_provider_home_state_jurisdiction( compact=compact, provider_id=provider_id ) top_level_provider_record = provider_user_records.get_provider_record() - current_home_jurisdiction = top_level_provider_record.currentHomeJurisdiction + home_jurisdiction_before_update = top_level_provider_record.currentHomeJurisdiction + if home_jurisdiction_before_update == selected_jurisdiction: + raise CCInvalidRequestException('New jurisdiction matches current home state.') # Get all licenses in the new home jurisdiction new_home_state_licenses = provider_user_records.get_license_records( @@ -2668,7 +2671,7 @@ def update_provider_home_state_jurisdiction( # Get licenses from the current home state current_home_state_licenses = provider_user_records.get_license_records( - filter_condition=lambda license_data: license_data.jurisdiction == current_home_jurisdiction + filter_condition=lambda license_data: license_data.jurisdiction == home_jurisdiction_before_update ) # Get unique license types from all privileges @@ -2691,7 +2694,7 @@ def update_provider_home_state_jurisdiction( 'User likely previously moved to a state with no known license ' 'and privileges were deactivated. Will not move privileges over.', license_type=license_type, - current_home_jurisdiction=current_home_jurisdiction, + current_home_jurisdiction=home_jurisdiction_before_update, new_home_state_licenses=new_home_state_licenses, ) continue @@ -2735,6 +2738,9 @@ def update_provider_home_state_jurisdiction( # Execute all transactions in batches self._execute_batched_transactions(all_transaction_items) + # Return the previous home jurisdiction + return home_jurisdiction_before_update + except Exception as e: logger.error( 'Failed to update provider home state jurisdiction', diff --git a/backend/compact-connect/lambdas/python/common/cc_common/event_bus_client.py b/backend/compact-connect/lambdas/python/common/cc_common/event_bus_client.py index 9a741bb02..0389e3cb8 100644 --- a/backend/compact-connect/lambdas/python/common/cc_common/event_bus_client.py +++ b/backend/compact-connect/lambdas/python/common/cc_common/event_bus_client.py @@ -8,6 +8,7 @@ from cc_common.data_model.schema.common import InvestigationAgainstEnum from cc_common.data_model.schema.data_event.api import ( EncumbranceEventDetailSchema, + HomeJurisdictionChangeEventDetailSchema, InvestigationEventDetailSchema, LicenseDeactivationDetailSchema, LicenseRevertDetailSchema, @@ -17,6 +18,7 @@ PrivilegeRenewalDetailSchema, PrivilegeRevertDetailSchema, ) +from cc_common.event_state_client import EventType from cc_common.event_batch_writer import EventBatchWriter from cc_common.utils import ResponseEncoder @@ -585,3 +587,37 @@ def publish_military_audit_event( detail=deserialized_detail, event_batch_writer=event_batch_writer, ) + + def publish_home_jurisdiction_change_event( + self, + source: str, + compact: str, + provider_id: str, + previous_home_jurisdiction: str | None, + new_home_jurisdiction: str, + ): + """ + Publish a home jurisdiction change event to the event bus. + + :param source: The source of the event + :param compact: The compact name + :param provider_id: The provider ID + :param previous_home_jurisdiction: Previous home jurisdiction (can be None) + :param new_home_jurisdiction: New home jurisdiction + """ + event_detail = { + 'compact': compact, + 'providerId': provider_id, + 'previousHomeJurisdiction': previous_home_jurisdiction, + 'newHomeJurisdiction': new_home_jurisdiction, + 'eventTime': config.current_standard_datetime, + } + + home_jurisdiction_change_detail_schema = HomeJurisdictionChangeEventDetailSchema() + deserialized_detail = home_jurisdiction_change_detail_schema.dump(event_detail) + + self._publish_event( + source=source, + detail_type=EventType.HOME_JURISDICTION_CHANGE.value, + detail=deserialized_detail, + ) diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/handlers/provider_users.py b/backend/compact-connect/lambdas/python/provider-data-v1/handlers/provider_users.py index e62f67413..f72f1c486 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/handlers/provider_users.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/handlers/provider_users.py @@ -108,9 +108,19 @@ def _put_provider_home_jurisdiction(event: dict, context: LambdaContext): # noq ) try: - config.data_client.update_provider_home_state_jurisdiction( + # Update provider home jurisdiction and get the previous home jurisdiction + previous_home_jurisdiction = config.data_client.update_provider_home_state_jurisdiction( compact=compact, provider_id=provider_id, selected_jurisdiction=selected_jurisdiction ) + + # Publish event for notification processing + config.event_bus_client.publish_home_jurisdiction_change_event( + source='org.compactconnect.provider-data', + compact=compact, + provider_id=provider_id, + previous_home_jurisdiction=previous_home_jurisdiction, + new_home_jurisdiction=selected_jurisdiction, + ) except CCInternalException as e: logger.error( 'Failed to update provider home jurisdiction', diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_provider_users_home_jurisdiction_change.py b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_provider_users_home_jurisdiction_change.py index 5e4fc9eb4..5d16b0a6e 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_provider_users_home_jurisdiction_change.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_provider_users_home_jurisdiction_change.py @@ -308,6 +308,22 @@ def test_put_provider_home_jurisdiction_returns_400_with_invalid_jurisdiction(se self.assertEqual({'message': 'Invalid jurisdiction selected.'}, resp_body) + def test_put_provider_home_jurisdiction_returns_400_with_same_jurisdiction_as_current(self): + from handlers.provider_users import provider_users_api_handler + + (test_provider_record, test_current_license_record, test_privilege_record) = ( + self._when_provider_has_one_license_and_privilege() + ) + + event = self._when_testing_put_provider_home_jurisdiction(STARTING_JURISDICTION, test_provider_record) + + resp = provider_users_api_handler(event, self.mock_context) + + self.assertEqual(400, resp['statusCode']) + resp_body = json.loads(resp['body']) + + self.assertEqual({'message': 'New jurisdiction matches current home state.'}, resp_body) + def test_put_provider_home_jurisdiction_returns_400_if_api_call_made_without_proper_claims(self): from handlers.provider_users import provider_users_api_handler @@ -1028,3 +1044,28 @@ def test_put_provider_home_jurisdiction_deactivates_privileges_if_new_jurisdicti # since in this case they should not be moved over self.assertEqual(test_current_license_record.dateOfExpiration, stored_privilege_data.dateOfExpiration) self.assertEqual(test_current_license_record.jurisdiction, stored_privilege_data.licenseJurisdiction) + + @patch('cc_common.event_bus_client.EventBusClient.publish_home_jurisdiction_change_event') + def test_put_provider_home_jurisdiction_handler_publishes_event(self, mock_publish_event): + """Test that provider home jurisdiction handler publishes the correct event.""" + from handlers.provider_users import provider_users_api_handler + + (test_provider_record, test_current_license_record, test_privilege_record) = ( + self._when_provider_has_one_license_and_privilege() + ) + + # Create a license in the new jurisdiction + self._when_provider_has_license_in_new_home_state() + event = self._when_testing_put_provider_home_jurisdiction(NEW_JURISDICTION, test_provider_record) + + response = provider_users_api_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode']) + + # Verify event was published with correct details + mock_publish_event.assert_called_once_with( + source='org.compactconnect.provider-data', + compact=test_provider_record.compact, + provider_id=test_provider_record.providerId, + previous_home_jurisdiction=STARTING_JURISDICTION, + new_home_jurisdiction=NEW_JURISDICTION, + ) From 533e88cc8fb0eae7e2dd48c81aa0f4b03dfdb422 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Wed, 28 Jan 2026 10:03:47 -0600 Subject: [PATCH 04/18] Add feature flag for home state change notification --- .../common/cc_common/feature_flag_enum.py | 1 + .../handlers/provider_users.py | 20 ++++++----- ...provider_users_home_jurisdiction_change.py | 35 ++++++++++++++++++- .../stacks/feature_flag_stack/__init__.py | 12 +++++++ 4 files changed, 59 insertions(+), 9 deletions(-) diff --git a/backend/compact-connect/lambdas/python/common/cc_common/feature_flag_enum.py b/backend/compact-connect/lambdas/python/common/cc_common/feature_flag_enum.py index 53e579568..e6d2aa5b9 100644 --- a/backend/compact-connect/lambdas/python/common/cc_common/feature_flag_enum.py +++ b/backend/compact-connect/lambdas/python/common/cc_common/feature_flag_enum.py @@ -12,3 +12,4 @@ class FeatureFlagEnum(StrEnum): TEST_FLAG = 'test-flag' # runtime flags DUPLICATE_SSN_UPLOAD_CHECK_FLAG = 'duplicate-ssn-upload-check-flag' + HOME_JURISDICTION_CHANGE_NOTIFICATION_FLAG = 'home-jurisdiction-change-notification-flag' diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/handlers/provider_users.py b/backend/compact-connect/lambdas/python/provider-data-v1/handlers/provider_users.py index f72f1c486..f77e100e1 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/handlers/provider_users.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/handlers/provider_users.py @@ -113,14 +113,18 @@ def _put_provider_home_jurisdiction(event: dict, context: LambdaContext): # noq compact=compact, provider_id=provider_id, selected_jurisdiction=selected_jurisdiction ) - # Publish event for notification processing - config.event_bus_client.publish_home_jurisdiction_change_event( - source='org.compactconnect.provider-data', - compact=compact, - provider_id=provider_id, - previous_home_jurisdiction=previous_home_jurisdiction, - new_home_jurisdiction=selected_jurisdiction, - ) + # Publish event for notification processing if feature flag is enabled + from cc_common.feature_flag_client import FeatureFlagEnum, is_feature_enabled + if is_feature_enabled( + FeatureFlagEnum.HOME_JURISDICTION_CHANGE_NOTIFICATION_FLAG, fail_default=False + ): + config.event_bus_client.publish_home_jurisdiction_change_event( + source='org.compactconnect.provider-data', + compact=compact, + provider_id=provider_id, + previous_home_jurisdiction=previous_home_jurisdiction, + new_home_jurisdiction=selected_jurisdiction, + ) except CCInternalException as e: logger.error( 'Failed to update provider home jurisdiction', diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_provider_users_home_jurisdiction_change.py b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_provider_users_home_jurisdiction_change.py index 5d16b0a6e..bb704c9ae 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_provider_users_home_jurisdiction_change.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_provider_users_home_jurisdiction_change.py @@ -1045,11 +1045,18 @@ def test_put_provider_home_jurisdiction_deactivates_privileges_if_new_jurisdicti self.assertEqual(test_current_license_record.dateOfExpiration, stored_privilege_data.dateOfExpiration) self.assertEqual(test_current_license_record.jurisdiction, stored_privilege_data.licenseJurisdiction) + # TODO - remove flag mock when flag is removed #noqa: FIX002 @patch('cc_common.event_bus_client.EventBusClient.publish_home_jurisdiction_change_event') - def test_put_provider_home_jurisdiction_handler_publishes_event(self, mock_publish_event): + @patch('cc_common.feature_flag_client.is_feature_enabled') + def test_put_provider_home_jurisdiction_handler_publishes_event( + self, mock_is_feature_enabled, mock_publish_event + ): """Test that provider home jurisdiction handler publishes the correct event.""" from handlers.provider_users import provider_users_api_handler + # Mock feature flag to return True + mock_is_feature_enabled.return_value = True + (test_provider_record, test_current_license_record, test_privilege_record) = ( self._when_provider_has_one_license_and_privilege() ) @@ -1069,3 +1076,29 @@ def test_put_provider_home_jurisdiction_handler_publishes_event(self, mock_publi previous_home_jurisdiction=STARTING_JURISDICTION, new_home_jurisdiction=NEW_JURISDICTION, ) + + # TODO - remove test when feature flag is removed #noqa: FIX002 + @patch('cc_common.event_bus_client.EventBusClient.publish_home_jurisdiction_change_event') + @patch('cc_common.feature_flag_client.is_feature_enabled') + def test_put_provider_home_jurisdiction_handler_does_not_publish_event_with_flag_off( + self, mock_is_feature_enabled, mock_publish_event + ): + """Test that provider home jurisdiction handler publishes the correct event.""" + from handlers.provider_users import provider_users_api_handler + + # Mock feature flag to return True + mock_is_feature_enabled.return_value = False + + (test_provider_record, test_current_license_record, test_privilege_record) = ( + self._when_provider_has_one_license_and_privilege() + ) + + # Create a license in the new jurisdiction + self._when_provider_has_license_in_new_home_state() + event = self._when_testing_put_provider_home_jurisdiction(NEW_JURISDICTION, test_provider_record) + + response = provider_users_api_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode']) + + # Verify event was published with correct details + mock_publish_event.assert_not_called() diff --git a/backend/compact-connect/stacks/feature_flag_stack/__init__.py b/backend/compact-connect/stacks/feature_flag_stack/__init__.py index fe12391f0..08933e1b6 100644 --- a/backend/compact-connect/stacks/feature_flag_stack/__init__.py +++ b/backend/compact-connect/stacks/feature_flag_stack/__init__.py @@ -127,6 +127,18 @@ def __init__( environment_name=environment_name, ) + self.home_jurisdiction_change_notification_flag = FeatureFlagResource( + self, + 'HomeJurisdictionChangeNotificationFlag', + provider=self.provider, # Shared provider + flag_name='home-jurisdiction-change-notification-flag', + # Start disabled by default, enable manually through console as needed + auto_enable_envs=[FeatureFlagEnvironmentName.TEST, + FeatureFlagEnvironmentName.BETA, + FeatureFlagEnvironmentName.PROD], + environment_name=environment_name, + ) + def _create_common_provider(self, environment_name: str) -> Provider: # Create shared Lambda function for managing all feature flags # This function is reused across all FeatureFlagResource instances From 5d5b44b1e254c1c9ed793c13fb5a75553cb27745 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Wed, 28 Jan 2026 10:41:38 -0600 Subject: [PATCH 05/18] Formatting email in case jurisdiction is set to 'other' --- .../lib/email/email-notification-service.ts | 21 ++++++++- .../tests/email-notification-service.test.ts | 27 ++++++++++++ .../email/email-notification-service.test.ts | 44 +++++++++++++++++++ .../common/cc_common/email_service_client.py | 2 +- 4 files changed, 92 insertions(+), 2 deletions(-) diff --git a/backend/compact-connect/lambdas/nodejs/lib/email/email-notification-service.ts b/backend/compact-connect/lambdas/nodejs/lib/email/email-notification-service.ts index 80cdaa401..1b348f5a3 100644 --- a/backend/compact-connect/lambdas/nodejs/lib/email/email-notification-service.ts +++ b/backend/compact-connect/lambdas/nodejs/lib/email/email-notification-service.ts @@ -561,6 +561,21 @@ export class EmailNotificationService extends BaseEmailService { await this.sendEmail({ htmlContent, subject, recipients, errorMessage: 'Unable to send military audit declined notification email' }); } + /** + * Converts 'other' jurisdiction to 'an unlisted jurisdiction' for display in email messages + * @param jurisdiction - The jurisdiction to convert + * @returns The converted jurisdiction string + */ + private formatJurisdictionForEmail(jurisdiction: string): string { + if (jurisdiction.toLowerCase() === 'other') { + return 'an unlisted jurisdiction'; + } + // else we uppercase the jurisdiction + else { + return jurisdiction.toUpperCase(); + } + } + /** * Sends a notification email to a jurisdiction operations team when a practitioner changes their home state * @param compact - The compact name @@ -595,10 +610,14 @@ export class EmailNotificationService extends BaseEmailService { throw new Error(`No recipients found for jurisdiction ${jurisdiction} in compact ${compact}`); } + // Convert 'other' to 'an unlisted jurisdiction' for email display + const formattedPreviousJurisdiction = this.formatJurisdictionForEmail(previousJurisdiction); + const formattedNewJurisdiction = this.formatJurisdictionForEmail(newJurisdiction); + const compactConfig = await this.compactConfigurationClient.getCompactConfiguration(compact); const report = this.getNewEmailTemplate(); const subject = `Practitioner Home State Change - ${compactConfig.compactName}`; - const bodyText = `This is to notify you that ${providerFirstName} ${providerLastName} has changed their home state from ${previousJurisdiction} to ${newJurisdiction}.\n\n` + + const bodyText = `This is to notify you that ${providerFirstName} ${providerLastName} has changed their home state from ${formattedPreviousJurisdiction} to ${formattedNewJurisdiction}.\n\n` + `Provider Details: ${environmentVariableService.getUiBasePathUrl()}/${compact}/Licensing/${providerId}\n\n` + 'If the above link does not work, you can copy and paste the url into a browser tab, where you are already logged in.'; diff --git a/backend/compact-connect/lambdas/nodejs/tests/email-notification-service.test.ts b/backend/compact-connect/lambdas/nodejs/tests/email-notification-service.test.ts index 717a07e0a..4bc6ebe32 100644 --- a/backend/compact-connect/lambdas/nodejs/tests/email-notification-service.test.ts +++ b/backend/compact-connect/lambdas/nodejs/tests/email-notification-service.test.ts @@ -1987,4 +1987,31 @@ describe('EmailNotificationServiceLambda', () => { .toThrow('Missing required template variables for privilegeInvestigationClosedStateNotification template.'); }); }); + + describe('Home Jurisdiction Change New State Notification', () => { + const SAMPLE_HOME_JURISDICTION_CHANGE_NEW_STATE_NOTIFICATION_EVENT: EmailNotificationEvent = { + template: 'homeJurisdictionChangeNewStateNotification', + recipientType: 'JURISDICTION_OPERATIONS_TEAM', + compact: 'aslp', + jurisdiction: 'oh', + templateVariables: { + providerFirstName: 'John', + providerLastName: 'Doe', + providerId: 'provider-123', + previousJurisdiction: 'TX', + newJurisdiction: 'OH' + } + }; + + 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.'); + }); + }); }); diff --git a/backend/compact-connect/lambdas/nodejs/tests/lib/email/email-notification-service.test.ts b/backend/compact-connect/lambdas/nodejs/tests/lib/email/email-notification-service.test.ts index b7f0b354c..a618f20a8 100644 --- a/backend/compact-connect/lambdas/nodejs/tests/lib/email/email-notification-service.test.ts +++ b/backend/compact-connect/lambdas/nodejs/tests/lib/email/email-notification-service.test.ts @@ -1022,5 +1022,49 @@ describe('EmailNotificationService', () => { 'OH' )).rejects.toThrow('No recipients found for jurisdiction oh in compact aslp'); }); + + it('should convert previous jurisdiction "other" to "an unlisted jurisdiction" in email content', async () => { + mockCompactConfigurationClient.getCompactConfiguration.mockResolvedValue(SAMPLE_COMPACT_CONFIG); + mockJurisdictionClient.getJurisdictionConfiguration.mockResolvedValue(SAMPLE_JURISDICTION_CONFIG); + + await emailService.sendHomeJurisdictionChangeStateNotificationEmail( + 'aslp', + 'oh', + 'Jane', + 'Smith', + 'provider-456', + 'other', + 'OH' + ); + + const emailCall = mockSESClient.commandCalls(SendEmailCommand)[0]; + const htmlContent = emailCall.args[0].input.Content?.Simple?.Body?.Html?.Data; + + expect(htmlContent).toBeDefined(); + expect(htmlContent).toContain('This is to notify you that Jane Smith has changed their home state from an unlisted jurisdiction to OH.'); + expect(htmlContent).not.toContain('from other to OH'); + }); + + it('should convert new jurisdiction "other" to "an unlisted jurisdiction" in email content', async () => { + mockCompactConfigurationClient.getCompactConfiguration.mockResolvedValue(SAMPLE_COMPACT_CONFIG); + mockJurisdictionClient.getJurisdictionConfiguration.mockResolvedValue(SAMPLE_JURISDICTION_CONFIG); + + await emailService.sendHomeJurisdictionChangeStateNotificationEmail( + 'aslp', + 'oh', + 'Bob', + 'Johnson', + 'provider-789', + 'TX', + 'other' + ); + + const emailCall = mockSESClient.commandCalls(SendEmailCommand)[0]; + const htmlContent = emailCall.args[0].input.Content?.Simple?.Body?.Html?.Data; + + expect(htmlContent).toBeDefined(); + expect(htmlContent).toContain('This is to notify you that Bob Johnson has changed their home state from TX to an unlisted jurisdiction.'); + expect(htmlContent).not.toContain('from TX to other'); + }); }); }); diff --git a/backend/compact-connect/lambdas/python/common/cc_common/email_service_client.py b/backend/compact-connect/lambdas/python/common/cc_common/email_service_client.py index 0aea109b9..ec72169e6 100644 --- a/backend/compact-connect/lambdas/python/common/cc_common/email_service_client.py +++ b/backend/compact-connect/lambdas/python/common/cc_common/email_service_client.py @@ -875,7 +875,7 @@ def send_home_jurisdiction_change_new_state_notification( 'providerFirstName': provider_first_name, 'providerLastName': provider_last_name, 'providerId': str(provider_id), - 'previousJurisdiction': previous_jurisdiction.upper() if previous_jurisdiction else 'Unlisted Jurisdiction', + 'previousJurisdiction': previous_jurisdiction.upper(), 'newJurisdiction': jurisdiction.upper(), }, } From f04fe70c2179694cc8d2895aa0e45362888871ca Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Wed, 28 Jan 2026 10:48:04 -0600 Subject: [PATCH 06/18] formatting/linter --- .../compact_configuration_client.py | 3 ++- .../cc_common/data_model/data_client.py | 3 ++- .../common/cc_common/event_bus_client.py | 2 +- .../handlers/home_jurisdiction_events.py | 9 +++++--- .../function/test_home_jurisdiction_events.py | 21 ++++++------------- .../handlers/provider_users.py | 5 ++--- ...provider_users_home_jurisdiction_change.py | 4 +--- .../stacks/feature_flag_stack/__init__.py | 6 ++++-- 8 files changed, 24 insertions(+), 29 deletions(-) diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/compact_configuration_client.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/compact_configuration_client.py index 37b042c18..f99e002a1 100644 --- a/backend/compact-connect/lambdas/python/common/cc_common/data_model/compact_configuration_client.py +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/compact_configuration_client.py @@ -248,7 +248,8 @@ def get_jurisdiction_operations_team_emails(self, compact: str, jurisdiction: st :param compact: The compact abbreviation :param jurisdiction: The jurisdiction postal abbreviation - :return: List of operations team email addresses, empty list if none configured, or None if jurisdiction not found + :return: List of operations team email addresses, empty list if none configured, + or None if jurisdiction not found """ logger.info('Getting jurisdiction operations team emails', compact=compact, jurisdiction=jurisdiction) diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/data_client.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/data_client.py index cd401fc44..639228539 100644 --- a/backend/compact-connect/lambdas/python/common/cc_common/data_model/data_client.py +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/data_client.py @@ -2671,7 +2671,8 @@ def update_provider_home_state_jurisdiction( # Get licenses from the current home state current_home_state_licenses = provider_user_records.get_license_records( - filter_condition=lambda license_data: license_data.jurisdiction == home_jurisdiction_before_update + filter_condition=lambda license_data: license_data.jurisdiction + == home_jurisdiction_before_update ) # Get unique license types from all privileges diff --git a/backend/compact-connect/lambdas/python/common/cc_common/event_bus_client.py b/backend/compact-connect/lambdas/python/common/cc_common/event_bus_client.py index 0389e3cb8..ebaaf4f14 100644 --- a/backend/compact-connect/lambdas/python/common/cc_common/event_bus_client.py +++ b/backend/compact-connect/lambdas/python/common/cc_common/event_bus_client.py @@ -18,8 +18,8 @@ PrivilegeRenewalDetailSchema, PrivilegeRevertDetailSchema, ) -from cc_common.event_state_client import EventType from cc_common.event_batch_writer import EventBatchWriter +from cc_common.event_state_client import EventType from cc_common.utils import ResponseEncoder diff --git a/backend/compact-connect/lambdas/python/data-events/handlers/home_jurisdiction_events.py b/backend/compact-connect/lambdas/python/data-events/handlers/home_jurisdiction_events.py index 42841985c..929c4ea92 100644 --- a/backend/compact-connect/lambdas/python/data-events/handlers/home_jurisdiction_events.py +++ b/backend/compact-connect/lambdas/python/data-events/handlers/home_jurisdiction_events.py @@ -4,7 +4,6 @@ from cc_common.data_model.schema.data_event.api import HomeJurisdictionChangeEventDetailSchema from cc_common.data_model.schema.fields import OTHER_JURISDICTION from cc_common.event_state_client import EventType, NotificationTracker, RecipientType -from cc_common.exceptions import CCInternalException from cc_common.utils import sqs_handler_with_notification_tracking @@ -103,8 +102,12 @@ def _send_old_state_notification( return if tracker.should_send_state_notification(previous_jurisdiction): - logger.info('Sending home jurisdiction change notification to old state', - compact=compact, provider_id=provider_id, jurisdiction=previous_jurisdiction) + logger.info( + 'Sending home jurisdiction change notification to old state', + compact=compact, + provider_id=provider_id, + jurisdiction=previous_jurisdiction, + ) try: config.email_service_client.send_home_jurisdiction_change_old_state_notification( compact=compact, diff --git a/backend/compact-connect/lambdas/python/data-events/tests/function/test_home_jurisdiction_events.py b/backend/compact-connect/lambdas/python/data-events/tests/function/test_home_jurisdiction_events.py index 6c91d1c4a..f20cda064 100644 --- a/backend/compact-connect/lambdas/python/data-events/tests/function/test_home_jurisdiction_events.py +++ b/backend/compact-connect/lambdas/python/data-events/tests/function/test_home_jurisdiction_events.py @@ -18,11 +18,9 @@ class TestHomeJurisdictionChangeEvents(TstFunction): """Test suite for home jurisdiction change event handlers.""" - def _generate_home_jurisdiction_change_message( - self, previous_jurisdiction: str | None, new_jurisdiction: str - ): + def _generate_home_jurisdiction_change_message(self, previous_jurisdiction: str | None, new_jurisdiction: str): """Generate a test EventBridge message for home jurisdiction change events.""" - message = { + return { 'detail': { 'compact': DEFAULT_COMPACT, 'providerId': DEFAULT_PROVIDER_ID, @@ -31,7 +29,6 @@ def _generate_home_jurisdiction_change_message( 'eventTime': DEFAULT_DATE_OF_UPDATE_TIMESTAMP, } } - return message def _create_sqs_event(self, message): """Create a proper SQS event structure with the message in the body.""" @@ -39,9 +36,7 @@ def _create_sqs_event(self, message): @patch('cc_common.email_service_client.EmailServiceClient.send_home_jurisdiction_change_old_state_notification') @patch('cc_common.email_service_client.EmailServiceClient.send_home_jurisdiction_change_new_state_notification') - def test_both_states_notified_when_changing_to_compact_state( - self, mock_send_new_state, mock_send_old_state - ): + def test_both_states_notified_when_changing_to_compact_state(self, mock_send_new_state, mock_send_old_state): """Test that both old and new states are notified when changing to another compact state.""" from handlers.home_jurisdiction_events import home_jurisdiction_change_notification_listener @@ -143,8 +138,8 @@ def test_only_old_state_notified_when_changing_to_other(self, mock_send_new_stat @patch('cc_common.email_service_client.EmailServiceClient.send_home_jurisdiction_change_new_state_notification') def test_no_old_state_notification_when_previous_is_other(self, mock_send_new_state, mock_send_old_state): """Test that old state is not notified when previous jurisdiction is 'other'.""" - from handlers.home_jurisdiction_events import home_jurisdiction_change_notification_listener from cc_common.data_model.schema.fields import OTHER_JURISDICTION + from handlers.home_jurisdiction_events import home_jurisdiction_change_notification_listener # Set up test data self.test_data_generator.put_default_provider_record_in_provider_table( @@ -273,9 +268,7 @@ def test_error_handling_records_failure(self, mock_send_old_state): @patch('cc_common.email_service_client.EmailServiceClient.send_home_jurisdiction_change_old_state_notification') @patch('cc_common.email_service_client.EmailServiceClient.send_home_jurisdiction_change_new_state_notification') - def test_no_notification_when_jurisdiction_has_no_operations_emails( - self, mock_send_new_state, mock_send_old_state - ): + def test_no_notification_when_jurisdiction_has_no_operations_emails(self, mock_send_new_state, mock_send_old_state): """Test that notifications are skipped when jurisdiction has no operations team emails.""" from handlers.home_jurisdiction_events import home_jurisdiction_change_notification_listener @@ -321,9 +314,7 @@ def test_no_notification_when_jurisdiction_has_no_operations_emails( @patch('cc_common.email_service_client.EmailServiceClient.send_home_jurisdiction_change_old_state_notification') @patch('cc_common.email_service_client.EmailServiceClient.send_home_jurisdiction_change_new_state_notification') - def test_no_notification_when_jurisdiction_config_not_found( - self, mock_send_new_state, mock_send_old_state - ): + def test_no_notification_when_jurisdiction_config_not_found(self, mock_send_new_state, mock_send_old_state): """Test that notifications are skipped when jurisdiction configuration is not found.""" from handlers.home_jurisdiction_events import home_jurisdiction_change_notification_listener diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/handlers/provider_users.py b/backend/compact-connect/lambdas/python/provider-data-v1/handlers/provider_users.py index f77e100e1..4eccbe1e1 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/handlers/provider_users.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/handlers/provider_users.py @@ -115,9 +115,8 @@ def _put_provider_home_jurisdiction(event: dict, context: LambdaContext): # noq # Publish event for notification processing if feature flag is enabled from cc_common.feature_flag_client import FeatureFlagEnum, is_feature_enabled - if is_feature_enabled( - FeatureFlagEnum.HOME_JURISDICTION_CHANGE_NOTIFICATION_FLAG, fail_default=False - ): + + if is_feature_enabled(FeatureFlagEnum.HOME_JURISDICTION_CHANGE_NOTIFICATION_FLAG, fail_default=False): config.event_bus_client.publish_home_jurisdiction_change_event( source='org.compactconnect.provider-data', compact=compact, diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_provider_users_home_jurisdiction_change.py b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_provider_users_home_jurisdiction_change.py index bb704c9ae..51425416a 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_provider_users_home_jurisdiction_change.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_provider_users_home_jurisdiction_change.py @@ -1048,9 +1048,7 @@ def test_put_provider_home_jurisdiction_deactivates_privileges_if_new_jurisdicti # TODO - remove flag mock when flag is removed #noqa: FIX002 @patch('cc_common.event_bus_client.EventBusClient.publish_home_jurisdiction_change_event') @patch('cc_common.feature_flag_client.is_feature_enabled') - def test_put_provider_home_jurisdiction_handler_publishes_event( - self, mock_is_feature_enabled, mock_publish_event - ): + def test_put_provider_home_jurisdiction_handler_publishes_event(self, mock_is_feature_enabled, mock_publish_event): """Test that provider home jurisdiction handler publishes the correct event.""" from handlers.provider_users import provider_users_api_handler diff --git a/backend/compact-connect/stacks/feature_flag_stack/__init__.py b/backend/compact-connect/stacks/feature_flag_stack/__init__.py index 08933e1b6..03bee3d26 100644 --- a/backend/compact-connect/stacks/feature_flag_stack/__init__.py +++ b/backend/compact-connect/stacks/feature_flag_stack/__init__.py @@ -133,9 +133,11 @@ def __init__( provider=self.provider, # Shared provider flag_name='home-jurisdiction-change-notification-flag', # Start disabled by default, enable manually through console as needed - auto_enable_envs=[FeatureFlagEnvironmentName.TEST, + auto_enable_envs=[ + FeatureFlagEnvironmentName.TEST, FeatureFlagEnvironmentName.BETA, - FeatureFlagEnvironmentName.PROD], + FeatureFlagEnvironmentName.PROD, + ], environment_name=environment_name, ) From 77c0c0a4b324cb827e058223c01703cdfdab5658 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Wed, 28 Jan 2026 11:55:17 -0600 Subject: [PATCH 07/18] PR feedback --- .../cc_common/data_model/data_client.py | 10 ++++- .../handlers/provider_users.py | 37 +++++++++++++------ ...provider_users_home_jurisdiction_change.py | 12 +++--- .../stacks/feature_flag_stack/__init__.py | 2 +- .../stacks/notification_stack.py | 1 + 5 files changed, 42 insertions(+), 20 deletions(-) diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/data_client.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/data_client.py index 639228539..cc411630b 100644 --- a/backend/compact-connect/lambdas/python/common/cc_common/data_model/data_client.py +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/data_client.py @@ -2594,8 +2594,14 @@ def update_provider_home_state_jurisdiction( ) top_level_provider_record = provider_user_records.get_provider_record() home_jurisdiction_before_update = top_level_provider_record.currentHomeJurisdiction - if home_jurisdiction_before_update == selected_jurisdiction: - raise CCInvalidRequestException('New jurisdiction matches current home state.') + if home_jurisdiction_before_update.lower() == selected_jurisdiction.lower(): + logger.info( + 'New selected jurisdiction matches current home state. Returning as this is a no-op', + compact=compact, + current_home_jurisdiction=home_jurisdiction_before_update, + selected_jurisdiction=selected_jurisdiction, + provider_id=provider_id, + ) # Get all licenses in the new home jurisdiction new_home_state_licenses = provider_user_records.get_license_records( diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/handlers/provider_users.py b/backend/compact-connect/lambdas/python/provider-data-v1/handlers/provider_users.py index 4eccbe1e1..838fa5fd6 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/handlers/provider_users.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/handlers/provider_users.py @@ -112,18 +112,6 @@ def _put_provider_home_jurisdiction(event: dict, context: LambdaContext): # noq previous_home_jurisdiction = config.data_client.update_provider_home_state_jurisdiction( compact=compact, provider_id=provider_id, selected_jurisdiction=selected_jurisdiction ) - - # Publish event for notification processing if feature flag is enabled - from cc_common.feature_flag_client import FeatureFlagEnum, is_feature_enabled - - if is_feature_enabled(FeatureFlagEnum.HOME_JURISDICTION_CHANGE_NOTIFICATION_FLAG, fail_default=False): - config.event_bus_client.publish_home_jurisdiction_change_event( - source='org.compactconnect.provider-data', - compact=compact, - provider_id=provider_id, - previous_home_jurisdiction=previous_home_jurisdiction, - new_home_jurisdiction=selected_jurisdiction, - ) except CCInternalException as e: logger.error( 'Failed to update provider home jurisdiction', @@ -134,6 +122,31 @@ def _put_provider_home_jurisdiction(event: dict, context: LambdaContext): # noq ) raise + # if user is attempting to set their home state to the same value already recorded on their record + # this is a no-op and we skip sending notifications + if previous_home_jurisdiction != selected_jurisdiction: + try: + # Publish event for notification processing if feature flag is enabled + from cc_common.feature_flag_client import FeatureFlagEnum, is_feature_enabled + + if is_feature_enabled(FeatureFlagEnum.HOME_JURISDICTION_CHANGE_NOTIFICATION_FLAG, fail_default=False): + config.event_bus_client.publish_home_jurisdiction_change_event( + source='org.compactconnect.provider-data', + compact=compact, + provider_id=provider_id, + previous_home_jurisdiction=previous_home_jurisdiction, + new_home_jurisdiction=selected_jurisdiction, + ) + except config.events_client.exceptions.InternalException: + # Log the error and continue + logger.error( + 'Failed to send home state license update notification event', + compact=compact, + previous_home_jurisdiction=previous_home_jurisdiction, + selected_jurisdiction=selected_jurisdiction, + provider_id=provider_id, + ) + return {'message': 'ok'} diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_provider_users_home_jurisdiction_change.py b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_provider_users_home_jurisdiction_change.py index 51425416a..35d77a8fe 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_provider_users_home_jurisdiction_change.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_provider_users_home_jurisdiction_change.py @@ -308,7 +308,10 @@ def test_put_provider_home_jurisdiction_returns_400_with_invalid_jurisdiction(se self.assertEqual({'message': 'Invalid jurisdiction selected.'}, resp_body) - def test_put_provider_home_jurisdiction_returns_400_with_same_jurisdiction_as_current(self): + @patch('cc_common.event_bus_client.EventBusClient.publish_home_jurisdiction_change_event') + def test_put_provider_home_jurisdiction_performs_no_op_when_selected_jurisdiction_same_as_current( + self, mock_publish + ): from handlers.provider_users import provider_users_api_handler (test_provider_record, test_current_license_record, test_privilege_record) = ( @@ -319,10 +322,9 @@ def test_put_provider_home_jurisdiction_returns_400_with_same_jurisdiction_as_cu resp = provider_users_api_handler(event, self.mock_context) - self.assertEqual(400, resp['statusCode']) - resp_body = json.loads(resp['body']) + self.assertEqual(200, resp['statusCode']) - self.assertEqual({'message': 'New jurisdiction matches current home state.'}, resp_body) + mock_publish.assert_not_called() def test_put_provider_home_jurisdiction_returns_400_if_api_call_made_without_proper_claims(self): from handlers.provider_users import provider_users_api_handler @@ -1084,7 +1086,7 @@ def test_put_provider_home_jurisdiction_handler_does_not_publish_event_with_flag """Test that provider home jurisdiction handler publishes the correct event.""" from handlers.provider_users import provider_users_api_handler - # Mock feature flag to return True + # Mock feature flag to return False mock_is_feature_enabled.return_value = False (test_provider_record, test_current_license_record, test_privilege_record) = ( diff --git a/backend/compact-connect/stacks/feature_flag_stack/__init__.py b/backend/compact-connect/stacks/feature_flag_stack/__init__.py index 03bee3d26..e2f30e6b3 100644 --- a/backend/compact-connect/stacks/feature_flag_stack/__init__.py +++ b/backend/compact-connect/stacks/feature_flag_stack/__init__.py @@ -132,7 +132,7 @@ def __init__( 'HomeJurisdictionChangeNotificationFlag', provider=self.provider, # Shared provider flag_name='home-jurisdiction-change-notification-flag', - # Start disabled by default, enable manually through console as needed + # Automatically enable for every environment auto_enable_envs=[ FeatureFlagEnvironmentName.TEST, FeatureFlagEnvironmentName.BETA, diff --git a/backend/compact-connect/stacks/notification_stack.py b/backend/compact-connect/stacks/notification_stack.py index 13dbfd780..d81676d9d 100644 --- a/backend/compact-connect/stacks/notification_stack.py +++ b/backend/compact-connect/stacks/notification_stack.py @@ -201,6 +201,7 @@ def _add_emailer_event_listener( timeout=Duration.minutes(1), environment={ 'PROVIDER_TABLE_NAME': persistent_stack.provider_table.table_name, + 'COMPACT_CONFIGURATION_TABLE_NAME': persistent_stack.compact_configuration_table.table_name, 'EMAIL_NOTIFICATION_SERVICE_LAMBDA_NAME': persistent_stack.email_notification_service_lambda.function_name, # noqa: E501 line-too-long 'EVENT_STATE_TABLE_NAME': event_state_stack.event_state_table.table_name, **self.common_env_vars, From 872d7a6caa2766f51ed21917159f0ab494deda7d Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Wed, 28 Jan 2026 12:06:56 -0600 Subject: [PATCH 08/18] PR feedback - require previous jurisdiction in event detail --- .../cc_common/data_model/schema/data_event/api.py | 2 +- .../python/common/cc_common/email_service_client.py | 12 ++++++------ .../data-events/handlers/home_jurisdiction_events.py | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/data_event/api.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/data_event/api.py index 0b98d67e3..db73d0a23 100644 --- a/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/data_event/api.py +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/data_event/api.py @@ -104,6 +104,6 @@ class HomeJurisdictionChangeEventDetailSchema(ForgivingSchema): compact = Compact(required=True, allow_none=False) providerId = UUID(required=True, allow_none=False) - previousHomeJurisdiction = String(required=False, allow_none=True) + previousHomeJurisdiction = String(required=True, allow_none=True) newHomeJurisdiction = String(required=True, allow_none=False) eventTime = DateTime(required=True, allow_none=False) diff --git a/backend/compact-connect/lambdas/python/common/cc_common/email_service_client.py b/backend/compact-connect/lambdas/python/common/cc_common/email_service_client.py index ec72169e6..248824aa2 100644 --- a/backend/compact-connect/lambdas/python/common/cc_common/email_service_client.py +++ b/backend/compact-connect/lambdas/python/common/cc_common/email_service_client.py @@ -839,8 +839,8 @@ def send_home_jurisdiction_change_old_state_notification( 'providerFirstName': provider_first_name, 'providerLastName': provider_last_name, 'providerId': str(provider_id), - 'previousJurisdiction': jurisdiction.upper(), - 'newJurisdiction': new_jurisdiction.upper(), + 'previousJurisdiction': jurisdiction, + 'newJurisdiction': new_jurisdiction, }, } return self._invoke_lambda(payload) @@ -853,7 +853,7 @@ def send_home_jurisdiction_change_new_state_notification( provider_first_name: str, provider_last_name: str, provider_id: UUID, - previous_jurisdiction: str | None, + previous_jurisdiction: str, ) -> dict[str, str]: """ Notify the new home state that a practitioner has selected them as their home jurisdiction. @@ -863,7 +863,7 @@ def send_home_jurisdiction_change_new_state_notification( :param provider_first_name: Provider's first name :param provider_last_name: Provider's last name :param provider_id: Provider ID - :param previous_jurisdiction: Previous home jurisdiction (can be None) + :param previous_jurisdiction: Previous home jurisdiction :return: Response from the email notification service """ payload = { @@ -875,8 +875,8 @@ def send_home_jurisdiction_change_new_state_notification( 'providerFirstName': provider_first_name, 'providerLastName': provider_last_name, 'providerId': str(provider_id), - 'previousJurisdiction': previous_jurisdiction.upper(), - 'newJurisdiction': jurisdiction.upper(), + 'previousJurisdiction': previous_jurisdiction, + 'newJurisdiction': jurisdiction, }, } return self._invoke_lambda(payload) diff --git a/backend/compact-connect/lambdas/python/data-events/handlers/home_jurisdiction_events.py b/backend/compact-connect/lambdas/python/data-events/handlers/home_jurisdiction_events.py index 929c4ea92..ae139c2e2 100644 --- a/backend/compact-connect/lambdas/python/data-events/handlers/home_jurisdiction_events.py +++ b/backend/compact-connect/lambdas/python/data-events/handlers/home_jurisdiction_events.py @@ -144,7 +144,7 @@ def _send_new_state_notification( compact: str, provider_id: UUID, provider_record, - previous_jurisdiction: str | None, + previous_jurisdiction: str, new_jurisdiction: str, event_time: str, tracker: NotificationTracker, From 4f40152a26f7d8d8952bcbbe268b60b1811bfed4 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Wed, 28 Jan 2026 12:11:15 -0600 Subject: [PATCH 09/18] PR feedback - broader exception --- .../lambdas/python/provider-data-v1/handlers/provider_users.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/handlers/provider_users.py b/backend/compact-connect/lambdas/python/provider-data-v1/handlers/provider_users.py index 838fa5fd6..c8c711d4a 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/handlers/provider_users.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/handlers/provider_users.py @@ -137,7 +137,7 @@ def _put_provider_home_jurisdiction(event: dict, context: LambdaContext): # noq previous_home_jurisdiction=previous_home_jurisdiction, new_home_jurisdiction=selected_jurisdiction, ) - except config.events_client.exceptions.InternalException: + except ClientError as e: # Log the error and continue logger.error( 'Failed to send home state license update notification event', @@ -145,6 +145,7 @@ def _put_provider_home_jurisdiction(event: dict, context: LambdaContext): # noq previous_home_jurisdiction=previous_home_jurisdiction, selected_jurisdiction=selected_jurisdiction, provider_id=provider_id, + error=str(e), ) return {'message': 'ok'} From d179f67922aa01f45ef359e631008400a034f5f2 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Wed, 28 Jan 2026 12:16:23 -0600 Subject: [PATCH 10/18] PR feedback - return for no-op --- .../lambdas/python/common/cc_common/data_model/data_client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/data_client.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/data_client.py index cc411630b..fd734b641 100644 --- a/backend/compact-connect/lambdas/python/common/cc_common/data_model/data_client.py +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/data_client.py @@ -2602,6 +2602,7 @@ def update_provider_home_state_jurisdiction( selected_jurisdiction=selected_jurisdiction, provider_id=provider_id, ) + return home_jurisdiction_before_update # Get all licenses in the new home jurisdiction new_home_state_licenses = provider_user_records.get_license_records( From fd173507c0c5bcfefff13d08ca10447f135f5480 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Wed, 28 Jan 2026 12:57:21 -0600 Subject: [PATCH 11/18] Add data event bus env var/permissions to lambda --- backend/compact-connect/stacks/api_lambda_stack/__init__.py | 1 + .../stacks/api_lambda_stack/provider_users.py | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/backend/compact-connect/stacks/api_lambda_stack/__init__.py b/backend/compact-connect/stacks/api_lambda_stack/__init__.py index 1b2128bae..714f0eab7 100644 --- a/backend/compact-connect/stacks/api_lambda_stack/__init__.py +++ b/backend/compact-connect/stacks/api_lambda_stack/__init__.py @@ -116,6 +116,7 @@ def __init__( persistent_stack=persistent_stack, provider_users_stack=provider_users_stack, api_lambda_stack=self, + data_event_bus=data_event_bus, ) # Provider Management lambdas diff --git a/backend/compact-connect/stacks/api_lambda_stack/provider_users.py b/backend/compact-connect/stacks/api_lambda_stack/provider_users.py index c8aabc816..0dce00f7a 100644 --- a/backend/compact-connect/stacks/api_lambda_stack/provider_users.py +++ b/backend/compact-connect/stacks/api_lambda_stack/provider_users.py @@ -5,6 +5,7 @@ from aws_cdk import Duration from aws_cdk.aws_cloudwatch import Alarm, CfnAlarm, ComparisonOperator, MathExpression, Metric, Stats, TreatMissingData from aws_cdk.aws_cloudwatch_actions import SnsAction +from aws_cdk.aws_events import EventBus from aws_cdk.aws_lambda import Code, Function, Runtime from aws_cdk.aws_logs import RetentionDays from aws_cdk.aws_secretsmanager import Secret @@ -26,9 +27,11 @@ def __init__( persistent_stack: ps.PersistentStack, provider_users_stack: ProviderUsersStack, api_lambda_stack: als.ApiLambdaStack, + data_event_bus: EventBus, ) -> None: self.persistent_stack = persistent_stack self.provider_users_stack = provider_users_stack + self.data_event_bus = data_event_bus stack = Stack.of(scope) lambda_environment = { @@ -41,6 +44,7 @@ def __init__( 'RATE_LIMITING_TABLE_NAME': persistent_stack.rate_limiting_table.table_name, 'COMPACT_CONFIGURATION_TABLE_NAME': persistent_stack.compact_configuration_table.table_name, 'EMAIL_NOTIFICATION_SERVICE_LAMBDA_NAME': persistent_stack.email_notification_service_lambda.function_name, + 'EVENT_BUS_NAME': data_event_bus.event_bus_name, **stack.common_env_vars, } @@ -379,6 +383,7 @@ def _create_provider_users_me_handler(self, scope: Construct, lambda_environment self.persistent_stack.provider_users_bucket.grant_read_write(provider_users_me_handler) self.persistent_stack.email_notification_service_lambda.grant_invoke(provider_users_me_handler) self.persistent_stack.compact_configuration_table.grant_read_data(provider_users_me_handler) + self.data_event_bus.grant_put_events_to(provider_users_me_handler) # Grant Cognito permissions for email update operations self.provider_users_stack.provider_users.grant(provider_users_me_handler, 'cognito-idp:AdminGetUser') self.provider_users_stack.provider_users.grant( From e22fd2b005caa15210425291c25f30117eec9673 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Wed, 28 Jan 2026 14:56:20 -0600 Subject: [PATCH 12/18] filter home jurisdiction change events from data event table --- .../lambdas/python/common/cc_common/event_state_client.py | 3 ++- .../lambdas/python/data-events/handlers/data_events.py | 3 ++- .../stacks/persistent_stack/data_event_table.py | 7 ++++--- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/backend/compact-connect/lambdas/python/common/cc_common/event_state_client.py b/backend/compact-connect/lambdas/python/common/cc_common/event_state_client.py index b4fad1a56..6e97e706f 100644 --- a/backend/compact-connect/lambdas/python/common/cc_common/event_state_client.py +++ b/backend/compact-connect/lambdas/python/common/cc_common/event_state_client.py @@ -21,7 +21,7 @@ class NotificationStatus(StrEnum): class EventType(StrEnum): - """Enum for event types that trigger notifications.""" + """Enum for event types on the event bus.""" LICENSE_ENCUMBRANCE = 'license.encumbrance' LICENSE_ENCUMBRANCE_LIFTED = 'license.encumbranceLifted' @@ -30,6 +30,7 @@ class EventType(StrEnum): MILITARY_AUDIT_APPROVED = 'militaryAffiliation.auditApproved' MILITARY_AUDIT_DECLINED = 'militaryAffiliation.auditDeclined' HOME_JURISDICTION_CHANGE = 'provider.homeJurisdictionChange' + LICENSE_INGEST = 'license.ingest' class EventStateClient: diff --git a/backend/compact-connect/lambdas/python/data-events/handlers/data_events.py b/backend/compact-connect/lambdas/python/data-events/handlers/data_events.py index 68808e053..c511318d5 100644 --- a/backend/compact-connect/lambdas/python/data-events/handlers/data_events.py +++ b/backend/compact-connect/lambdas/python/data-events/handlers/data_events.py @@ -2,6 +2,7 @@ from cc_common.config import config, logger from cc_common.data_model.schema.license.ingest import SanitizedLicenseIngestDataEventSchema +from cc_common.event_state_client import EventType from cc_common.utils import sqs_handler @@ -12,7 +13,7 @@ def handle_data_events(message: dict): event_type = message['detail-type'] # in the case of a licence.ingest event, we sanitize the PII from the license record - if event_type == 'license.ingest': + if event_type == EventType.LICENSE_INGEST: sanitized_schema = SanitizedLicenseIngestDataEventSchema() # by loading and dumping the data, we ensure that the data is sanitized as the schema # will remove all fields that are not explicitly defined in the schema diff --git a/backend/compact-connect/stacks/persistent_stack/data_event_table.py b/backend/compact-connect/stacks/persistent_stack/data_event_table.py index ee4876481..259045c34 100644 --- a/backend/compact-connect/stacks/persistent_stack/data_event_table.py +++ b/backend/compact-connect/stacks/persistent_stack/data_event_table.py @@ -115,9 +115,10 @@ def __init__( self, 'EventReceiverRule', event_bus=event_bus, - # match any event detail_type - # https://stackoverflow.com/a/62407802 - event_pattern=EventPattern(detail_type=Match.prefix('')), + # match any event detail_type except provider.homeJurisdictionChange + # (home jurisdiction change events are already recorded in provider history, and are not specific to one jurisdiction + # so we don't need to record them in the data events table) + event_pattern=EventPattern(detail_type=Match.anything_but('provider.homeJurisdictionChange')), targets=[SqsQueue(self.event_processor.queue, dead_letter_queue=self.event_processor.dlq)], ) From 558068ee18ea0c67f25946a09197462ded965b25 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Wed, 28 Jan 2026 14:56:57 -0600 Subject: [PATCH 13/18] Update smoke tests to test 'other' jurisdiction case --- .../home_jurisdiction_change_smoke_tests.py | 41 +++++++++++++++---- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/backend/compact-connect/tests/smoke/home_jurisdiction_change_smoke_tests.py b/backend/compact-connect/tests/smoke/home_jurisdiction_change_smoke_tests.py index e2fd3b997..58c31cd8d 100644 --- a/backend/compact-connect/tests/smoke/home_jurisdiction_change_smoke_tests.py +++ b/backend/compact-connect/tests/smoke/home_jurisdiction_change_smoke_tests.py @@ -10,7 +10,7 @@ SmokeTestFailureException, call_provider_users_me_endpoint, get_provider_user_auth_headers_cached, - load_smoke_test_env, + load_smoke_test_env ) # This script can be run locally to test the home jurisdiction change flow against a sandbox environment @@ -22,6 +22,31 @@ TEST_EXPIRATION_DATE = '2050-04-04' +def test_home_jurisdiction_change_to_oh(): + """ + Simple smoke test that sets the practitioner's home state to 'oh' and verifies a 200 response. + This test runs first to verify basic functionality before more complex tests. + """ + logger.info('Running basic home jurisdiction change test - setting to oh') + # ensure we have jurisdiction config for oh + test_jurisdiction_configuration(jurisdiction='oh', recreate_compact_config=True) + + # Set home jurisdiction to 'oh' + logger.info('Setting home jurisdiction to oh') + response = requests.put( + f'{config.api_base_url}/v1/provider-users/me/home-jurisdiction', + headers=get_provider_user_auth_headers_cached(), + json={'jurisdiction': 'oh'}, + timeout=30, + ) + + # Verify the response status code + if response.status_code != 200: + raise SmokeTestFailureException(f'Expected 200 status code, got {response.status_code}: {response.text}') + + logger.info(f'Successfully set home jurisdiction to oh. Response: {response.text}') + + def test_home_jurisdiction_change_inactivates_privileges_when_no_license_in_new_jurisdiction(): """ Test that when a provider changes their home jurisdiction to a jurisdiction where they don't have a license: @@ -42,13 +67,9 @@ def test_home_jurisdiction_change_inactivates_privileges_when_no_license_in_new_ original_jurisdiction = provider_info_before.get('currentHomeJurisdiction') logger.info(f'Original home jurisdiction: {original_jurisdiction}') - new_jurisdiction = 'al' # Alabama - assuming the provider doesn't have a license here + new_jurisdiction = 'other' # 'other' is the term used by the system for an unlisted jurisdiction - # we must ensure we have a valid live jurisdiction configuration in place for the current, new, and privilege - # states so the privilege can be moved over successfully - test_jurisdiction_configuration(jurisdiction=original_jurisdiction, recreate_compact_config=True) - test_jurisdiction_configuration(jurisdiction=new_jurisdiction, recreate_compact_config=False) - # privilege jurisdiction + # we must ensure we have a valid live jurisdiction configuration in place for the privilege jurisdiction test_jurisdiction_configuration(jurisdiction='ne', recreate_compact_config=False) # Purchase a privilege for the provider @@ -237,6 +258,9 @@ def test_home_jurisdiction_change_moves_privileges_when_valid_license_in_new_jur new_jurisdiction = 'al' # Alabama - assuming the provider doesn't have a license here logger.info(f'Original home jurisdiction: {original_jurisdiction}') + # create jurisdiction config for AL + test_jurisdiction_configuration(jurisdiction=new_jurisdiction, recreate_compact_config=False) + # In this test, we temporarily add a valid license for the provider in the new jurisdiction, # then move the user to the new jurisdiction # and verify that the privilege is moved to the new jurisdiction @@ -366,6 +390,7 @@ def test_home_jurisdiction_change_moves_privileges_when_valid_license_in_new_jur # Load environment variables from smoke_tests_env.json load_smoke_test_env() - # Run test + # Run tests + test_home_jurisdiction_change_to_oh() test_home_jurisdiction_change_inactivates_privileges_when_no_license_in_new_jurisdiction() test_home_jurisdiction_change_moves_privileges_when_valid_license_in_new_jurisdiction() From 376461144852252f237c98df657836034280eb75 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Wed, 28 Jan 2026 15:59:47 -0600 Subject: [PATCH 14/18] Update smoke tests to read test privilege data from db directly --- .../home_jurisdiction_change_smoke_tests.py | 88 ++++++++++++------- 1 file changed, 54 insertions(+), 34 deletions(-) diff --git a/backend/compact-connect/tests/smoke/home_jurisdiction_change_smoke_tests.py b/backend/compact-connect/tests/smoke/home_jurisdiction_change_smoke_tests.py index 58c31cd8d..44bb49357 100644 --- a/backend/compact-connect/tests/smoke/home_jurisdiction_change_smoke_tests.py +++ b/backend/compact-connect/tests/smoke/home_jurisdiction_change_smoke_tests.py @@ -10,6 +10,7 @@ SmokeTestFailureException, call_provider_users_me_endpoint, get_provider_user_auth_headers_cached, + get_provider_user_records, load_smoke_test_env ) @@ -125,23 +126,28 @@ def test_home_jurisdiction_change_inactivates_privileges_when_no_license_in_new_ ) # Verify all privileges now have homeJurisdictionChangeStatus as inactive - privileges_after_change = provider_info_after_change.get('privileges', []) - ne_privilege_after_change = next( - (privilege for privilege in privileges_after_change if privilege['jurisdiction'] == 'ne'), None + # Read privilege directly from database to access homeJurisdictionChangeStatus field + provider_id = provider_info_after_change.get('providerId') + compact = provider_info_after_change.get('compact') + provider_user_records = get_provider_user_records(compact=compact, provider_id=provider_id) + ne_privileges = provider_user_records.get_privilege_records( + filter_condition=lambda p: p.jurisdiction == 'ne' ) - if not ne_privilege_after_change: + if not ne_privileges: raise SmokeTestFailureException('Nebraska privilege not found after home jurisdiction change') - if ne_privilege_after_change.get('homeJurisdictionChangeStatus') != 'inactive': + ne_privilege = ne_privileges[0] + + if ne_privilege.homeJurisdictionChangeStatus != 'inactive': raise SmokeTestFailureException( f"Privilege homeJurisdictionChangeStatus should be 'inactive', " - f"but got '{ne_privilege_after_change.get('homeJurisdictionChangeStatus')}'" + f"but got '{ne_privilege.homeJurisdictionChangeStatus}'" ) - if ne_privilege_after_change.get('status') != 'inactive': + if ne_privilege.status != 'inactive': raise SmokeTestFailureException( - f"Privilege status should be 'inactive', but got '{ne_privilege_after_change.get('status')}'" + f"Privilege status should be 'inactive', but got '{ne_privilege.status}'" ) # change home jurisdiction back to original @@ -289,38 +295,44 @@ def test_home_jurisdiction_change_moves_privileges_when_valid_license_in_new_jur # get the provider's information after the home jurisdiction change provider_info_after_change = call_provider_users_me_endpoint() - # verify the privilege is moved to the new jurisdiction - privileges_after_change = provider_info_after_change.get('privileges', []) - ne_privilege_after_change = next( - (privilege for privilege in privileges_after_change if privilege['jurisdiction'] == 'ne'), None + # Read privilege directly from database to access homeJurisdictionChangeStatus field + provider_id = provider_info_after_change.get('providerId') + compact = provider_info_after_change.get('compact') + provider_user_records_after_change = get_provider_user_records(compact=compact, provider_id=provider_id) + ne_privileges_after_change = provider_user_records_after_change.get_privilege_records( + filter_condition=lambda p: p.jurisdiction == 'ne' ) - if not ne_privilege_after_change: + + if not ne_privileges_after_change: raise SmokeTestFailureException('Nebraska privilege not found after home jurisdiction change') - if ne_privilege_after_change.get('status') != 'active': + + ne_privilege_after_change = ne_privileges_after_change[0] + + if ne_privilege_after_change.status != 'active': raise SmokeTestFailureException( - f"Privilege should be 'active', but got '{ne_privilege_after_change.get('status')}'" + f"Privilege should be 'active', but got '{ne_privilege_after_change.status}'" ) logger.info('privilege is active after home jurisdiction change') - if ne_privilege_after_change.get('homeJurisdictionChangeStatus') is not None: + if ne_privilege_after_change.homeJurisdictionChangeStatus is not None: raise SmokeTestFailureException( f"Privilege should not have 'homeJurisdictionChangeStatus' field but found" - f" '{ne_privilege_after_change.get('homeJurisdictionChangeStatus')}'" + f" '{ne_privilege_after_change.homeJurisdictionChangeStatus}'" ) # verify the privilege licenseJurisdiction is the new jurisdiction - if ne_privilege_after_change.get('licenseJurisdiction') != new_jurisdiction: + if ne_privilege_after_change.licenseJurisdiction != new_jurisdiction: raise SmokeTestFailureException( f"Privilege licenseJurisdiction should be '{new_jurisdiction}', " - f"but got '{ne_privilege_after_change.get('licenseJurisdiction')}'" + f"but got '{ne_privilege_after_change.licenseJurisdiction}'" ) logger.info('privilege licenseJurisdiction is the new jurisdiction') # verify the expiration date is the same as the license expiration date - if ne_privilege_after_change.get('dateOfExpiration') != TEST_EXPIRATION_DATE: + if ne_privilege_after_change.dateOfExpiration.isoformat() != TEST_EXPIRATION_DATE: raise SmokeTestFailureException( f"Privilege dateOfExpiration should be '{TEST_EXPIRATION_DATE}', " - f"but got '{ne_privilege_after_change.get('dateOfExpiration')}'" + f"but got '{ne_privilege_after_change.dateOfExpiration.isoformat()}'" ) logger.info('privilege dateOfExpiration is the new expiration date') # now move the home jurisdiction back to the original jurisdiction and verify the privilege is moved back @@ -340,38 +352,46 @@ def test_home_jurisdiction_change_moves_privileges_when_valid_license_in_new_jur # get the provider's information after the home jurisdiction change provider_info_after_restore = call_provider_users_me_endpoint() - # verify the privilege is moved back to the original jurisdiction - privileges_after_restore = provider_info_after_restore.get('privileges', []) - ne_privilege_after_restore = next( - (privilege for privilege in privileges_after_restore if privilege['jurisdiction'] == 'ne'), None + # Read privilege directly from database to access homeJurisdictionChangeStatus field + provider_id_after_restore = provider_info_after_restore.get('providerId') + compact_after_restore = provider_info_after_restore.get('compact') + provider_user_records_after_restore = get_provider_user_records( + compact=compact_after_restore, provider_id=provider_id_after_restore ) - if not ne_privilege_after_restore: + ne_privileges_after_restore = provider_user_records_after_restore.get_privilege_records( + filter_condition=lambda p: p.jurisdiction == 'ne' + ) + + if not ne_privileges_after_restore: raise SmokeTestFailureException('Nebraska privilege not found after home jurisdiction change') - if ne_privilege_after_restore.get('status') != 'active': + + ne_privilege_after_restore = ne_privileges_after_restore[0] + + if ne_privilege_after_restore.status != 'active': raise SmokeTestFailureException( - f"Privilege should be 'active', but got '{ne_privilege_after_restore.get('status')}'" + f"Privilege should be 'active', but got '{ne_privilege_after_restore.status}'" ) logger.info('privilege still has active status') - if ne_privilege_after_restore.get('homeJurisdictionChangeStatus') is not None: + if ne_privilege_after_restore.homeJurisdictionChangeStatus is not None: raise SmokeTestFailureException( f"Privilege should not have 'homeJurisdictionChangeStatus' field but found" - f" '{ne_privilege_after_restore.get('homeJurisdictionChangeStatus')}'" + f" '{ne_privilege_after_restore.homeJurisdictionChangeStatus}'" ) # verify the privilege licenseJurisdiction is the original jurisdiction - if ne_privilege_after_restore.get('licenseJurisdiction') != original_jurisdiction: + if ne_privilege_after_restore.licenseJurisdiction != original_jurisdiction: raise SmokeTestFailureException( f"Privilege licenseJurisdiction should be '{original_jurisdiction}', " - f"but got '{ne_privilege_after_restore.get('licenseJurisdiction')}'" + f"but got '{ne_privilege_after_restore.licenseJurisdiction}'" ) logger.info('privilege licenseJurisdiction is the original jurisdiction') # verify the expiration date is the same as the license expiration date - if ne_privilege_after_restore.get('dateOfExpiration') != original_expiration_date: + if ne_privilege_after_restore.dateOfExpiration.isoformat() != original_expiration_date: raise SmokeTestFailureException( f"Privilege dateOfExpiration should be '{original_expiration_date}', " - f"but got '{ne_privilege_after_restore.get('dateOfExpiration')}'" + f"but got '{ne_privilege_after_restore.dateOfExpiration.isoformat()}'" ) logger.info('privilege dateOfExpiration is the original expiration date') From df276f28fea3dfe560a090b882d64a1ce89951a7 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Wed, 28 Jan 2026 16:01:52 -0600 Subject: [PATCH 15/18] Formatting --- .../stacks/persistent_stack/data_event_table.py | 4 ++-- .../smoke/home_jurisdiction_change_smoke_tests.py | 10 +++------- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/backend/compact-connect/stacks/persistent_stack/data_event_table.py b/backend/compact-connect/stacks/persistent_stack/data_event_table.py index 259045c34..4abe04378 100644 --- a/backend/compact-connect/stacks/persistent_stack/data_event_table.py +++ b/backend/compact-connect/stacks/persistent_stack/data_event_table.py @@ -116,8 +116,8 @@ def __init__( 'EventReceiverRule', event_bus=event_bus, # match any event detail_type except provider.homeJurisdictionChange - # (home jurisdiction change events are already recorded in provider history, and are not specific to one jurisdiction - # so we don't need to record them in the data events table) + # (home jurisdiction change events are already recorded in provider history, and are not specific + # to one jurisdiction so we don't need to record them in the data events table) event_pattern=EventPattern(detail_type=Match.anything_but('provider.homeJurisdictionChange')), targets=[SqsQueue(self.event_processor.queue, dead_letter_queue=self.event_processor.dlq)], ) diff --git a/backend/compact-connect/tests/smoke/home_jurisdiction_change_smoke_tests.py b/backend/compact-connect/tests/smoke/home_jurisdiction_change_smoke_tests.py index 44bb49357..d5fa30f16 100644 --- a/backend/compact-connect/tests/smoke/home_jurisdiction_change_smoke_tests.py +++ b/backend/compact-connect/tests/smoke/home_jurisdiction_change_smoke_tests.py @@ -11,7 +11,7 @@ call_provider_users_me_endpoint, get_provider_user_auth_headers_cached, get_provider_user_records, - load_smoke_test_env + load_smoke_test_env, ) # This script can be run locally to test the home jurisdiction change flow against a sandbox environment @@ -130,9 +130,7 @@ def test_home_jurisdiction_change_inactivates_privileges_when_no_license_in_new_ provider_id = provider_info_after_change.get('providerId') compact = provider_info_after_change.get('compact') provider_user_records = get_provider_user_records(compact=compact, provider_id=provider_id) - ne_privileges = provider_user_records.get_privilege_records( - filter_condition=lambda p: p.jurisdiction == 'ne' - ) + ne_privileges = provider_user_records.get_privilege_records(filter_condition=lambda p: p.jurisdiction == 'ne') if not ne_privileges: raise SmokeTestFailureException('Nebraska privilege not found after home jurisdiction change') @@ -146,9 +144,7 @@ def test_home_jurisdiction_change_inactivates_privileges_when_no_license_in_new_ ) if ne_privilege.status != 'inactive': - raise SmokeTestFailureException( - f"Privilege status should be 'inactive', but got '{ne_privilege.status}'" - ) + raise SmokeTestFailureException(f"Privilege status should be 'inactive', but got '{ne_privilege.status}'") # change home jurisdiction back to original logger.info(f'Restoring original home jurisdiction: {original_jurisdiction}') From f7cf7f0d3b8dd477c0dc4798991798fe48d4117f Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Thu, 29 Jan 2026 11:36:56 -0600 Subject: [PATCH 16/18] PR feedback - fix test param --- .../tests/function/test_home_jurisdiction_events.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/compact-connect/lambdas/python/data-events/tests/function/test_home_jurisdiction_events.py b/backend/compact-connect/lambdas/python/data-events/tests/function/test_home_jurisdiction_events.py index f20cda064..0d68b40ea 100644 --- a/backend/compact-connect/lambdas/python/data-events/tests/function/test_home_jurisdiction_events.py +++ b/backend/compact-connect/lambdas/python/data-events/tests/function/test_home_jurisdiction_events.py @@ -18,7 +18,7 @@ class TestHomeJurisdictionChangeEvents(TstFunction): """Test suite for home jurisdiction change event handlers.""" - def _generate_home_jurisdiction_change_message(self, previous_jurisdiction: str | None, new_jurisdiction: str): + def _generate_home_jurisdiction_change_message(self, previous_jurisdiction: str, new_jurisdiction: str): """Generate a test EventBridge message for home jurisdiction change events.""" return { 'detail': { @@ -160,7 +160,7 @@ def test_no_old_state_notification_when_previous_is_other(self, mock_send_new_st } ) - message = self._generate_home_jurisdiction_change_message(None, 'tx') + message = self._generate_home_jurisdiction_change_message(OTHER_JURISDICTION, 'tx') event = self._create_sqs_event(message) # Execute the handler From 66eeb408d599c29aec40735640f0f64985bf7516 Mon Sep 17 00:00:00 2001 From: Landon Shumway Date: Thu, 29 Jan 2026 13:48:18 -0600 Subject: [PATCH 17/18] update snapshot for event bridge rule --- .../tests/resources/snapshots/DATA_EVENT_RULE.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/compact-connect/tests/resources/snapshots/DATA_EVENT_RULE.json b/backend/compact-connect/tests/resources/snapshots/DATA_EVENT_RULE.json index e1bf16f53..114d05120 100644 --- a/backend/compact-connect/tests/resources/snapshots/DATA_EVENT_RULE.json +++ b/backend/compact-connect/tests/resources/snapshots/DATA_EVENT_RULE.json @@ -7,7 +7,9 @@ "EventPattern": { "detail-type": [ { - "prefix": "" + "anything-but": [ + "provider.homeJurisdictionChange" + ] } ] }, From 5d89b7719fb2fc545e96782ec0b7680726364215 Mon Sep 17 00:00:00 2001 From: landonshumway-ia Date: Thu, 29 Jan 2026 16:24:56 -0600 Subject: [PATCH 18/18] Update backend/compact-connect/lambdas/python/data-events/tests/function/test_home_jurisdiction_events.py Co-authored-by: Joshua Kravitz --- .../data-events/tests/function/test_home_jurisdiction_events.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/compact-connect/lambdas/python/data-events/tests/function/test_home_jurisdiction_events.py b/backend/compact-connect/lambdas/python/data-events/tests/function/test_home_jurisdiction_events.py index 0d68b40ea..f314af838 100644 --- a/backend/compact-connect/lambdas/python/data-events/tests/function/test_home_jurisdiction_events.py +++ b/backend/compact-connect/lambdas/python/data-events/tests/function/test_home_jurisdiction_events.py @@ -36,7 +36,7 @@ def _create_sqs_event(self, message): @patch('cc_common.email_service_client.EmailServiceClient.send_home_jurisdiction_change_old_state_notification') @patch('cc_common.email_service_client.EmailServiceClient.send_home_jurisdiction_change_new_state_notification') - def test_both_states_notified_when_changing_to_compact_state(self, mock_send_new_state, mock_send_old_state): + def test_both_states_notified_when_changing_compact_state(self, mock_send_new_state, mock_send_old_state): """Test that both old and new states are notified when changing to another compact state.""" from handlers.home_jurisdiction_events import home_jurisdiction_change_notification_listener