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..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 @@ -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,73 @@ 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 + * @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}`); + } + + // 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 ${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.'; + + 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/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 2befda98e..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 @@ -955,4 +955,116 @@ 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'); + }); + + 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/data_model/compact_configuration_client.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/compact_configuration_client.py index a31b15097..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 @@ -238,6 +238,42 @@ 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/data_client.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/data_client.py index b250176b2..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 @@ -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,16 @@ 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.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, + ) + return home_jurisdiction_before_update # Get all licenses in the new home jurisdiction new_home_state_licenses = provider_user_records.get_license_records( @@ -2668,7 +2678,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 == current_home_jurisdiction + filter_condition=lambda license_data: license_data.jurisdiction + == home_jurisdiction_before_update ) # Get unique license types from all privileges @@ -2691,7 +2702,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 +2746,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/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..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 @@ -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=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 d655c06b3..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 @@ -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, + 'newJurisdiction': new_jurisdiction, + }, + } + 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, + ) -> 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 + :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, + 'newJurisdiction': jurisdiction, + }, + } + return self._invoke_lambda(payload) 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..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 @@ -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, @@ -18,6 +19,7 @@ PrivilegeRevertDetailSchema, ) from cc_common.event_batch_writer import EventBatchWriter +from cc_common.event_state_client import EventType 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/common/cc_common/event_state_client.py b/backend/compact-connect/lambdas/python/common/cc_common/event_state_client.py index 6849357b9..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' @@ -29,6 +29,8 @@ class EventType(StrEnum): PRIVILEGE_ENCUMBRANCE_LIFTED = 'privilege.encumbranceLifted' 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/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/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/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..ae139c2e2 --- /dev/null +++ b/backend/compact-connect/lambdas/python/data-events/handlers/home_jurisdiction_events.py @@ -0,0 +1,197 @@ +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.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, + 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..f314af838 --- /dev/null +++ b/backend/compact-connect/lambdas/python/data-events/tests/function/test_home_jurisdiction_events.py @@ -0,0 +1,343 @@ +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, new_jurisdiction: str): + """Generate a test EventBridge message for home jurisdiction change events.""" + return { + 'detail': { + 'compact': DEFAULT_COMPACT, + 'providerId': DEFAULT_PROVIDER_ID, + 'previousHomeJurisdiction': previous_jurisdiction, + 'newHomeJurisdiction': new_jurisdiction, + 'eventTime': DEFAULT_DATE_OF_UPDATE_TIMESTAMP, + } + } + + 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_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 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( + 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(OTHER_JURISDICTION, '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/lambdas/python/provider-data-v1/handlers/provider_users.py b/backend/compact-connect/lambdas/python/provider-data-v1/handlers/provider_users.py index e62f67413..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 @@ -108,7 +108,8 @@ 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 ) except CCInternalException as e: @@ -121,6 +122,32 @@ 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 ClientError as e: + # 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, + error=str(e), + ) + 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 5e4fc9eb4..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,6 +308,24 @@ def test_put_provider_home_jurisdiction_returns_400_with_invalid_jurisdiction(se self.assertEqual({'message': 'Invalid jurisdiction selected.'}, resp_body) + @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) = ( + 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(200, resp['statusCode']) + + 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 @@ -1028,3 +1046,59 @@ 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) + + # 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): + """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() + ) + + # 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, + ) + + # 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 False + 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/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( diff --git a/backend/compact-connect/stacks/feature_flag_stack/__init__.py b/backend/compact-connect/stacks/feature_flag_stack/__init__.py index fe12391f0..e2f30e6b3 100644 --- a/backend/compact-connect/stacks/feature_flag_stack/__init__.py +++ b/backend/compact-connect/stacks/feature_flag_stack/__init__.py @@ -127,6 +127,20 @@ 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', + # Automatically enable for every environment + 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 diff --git a/backend/compact-connect/stacks/notification_stack.py b/backend/compact-connect/stacks/notification_stack.py index f5266a9d0..d81676d9d 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 @@ -198,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, @@ -207,6 +211,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 +365,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, + ) 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..4abe04378 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)], ) 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" + ] } ] }, 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..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 @@ -10,6 +10,7 @@ SmokeTestFailureException, call_provider_users_me_endpoint, get_provider_user_auth_headers_cached, + get_provider_user_records, load_smoke_test_env, ) @@ -22,6 +23,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 +68,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 @@ -104,24 +126,25 @@ 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': - raise SmokeTestFailureException( - f"Privilege status should be 'inactive', but got '{ne_privilege_after_change.get('status')}'" - ) + if ne_privilege.status != 'inactive': + 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}') @@ -237,6 +260,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 @@ -265,38 +291,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 @@ -316,38 +348,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 + ) + ne_privileges_after_restore = provider_user_records_after_restore.get_privilege_records( + filter_condition=lambda p: p.jurisdiction == 'ne' ) - if not ne_privilege_after_restore: + + 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') @@ -366,6 +406,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()