Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}
Expand Down Expand Up @@ -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<void> {
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' });
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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('<!DOCTYPE html>')
}
},
Subject: {
Charset: 'UTF-8',
Data: 'Practitioner Home State Change - Audiology and Speech Language Pathology'
}
}
},
FromEmailAddress: 'Compact Connect <noreply@example.org>'
}
);

// 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.');
Comment thread
jlkravitz marked this conversation as resolved.
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');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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')
Expand All @@ -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

Comment thread
coderabbitai[bot] marked this conversation as resolved.
# Get all licenses in the new home jurisdiction
new_home_state_licenses = provider_user_records.get_license_records(
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading
Loading