Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
164f861
Add 'lastLoginAt' field to staff user schemas
landonshumway-ia Aug 10, 2026
5ed7915
Add data class for staff user data
landonshumway-ia Aug 10, 2026
43f0dec
Add client method to record login timestamp
landonshumway-ia Aug 10, 2026
047e5ab
Add hook to update login timestamp to token generation lambda
landonshumway-ia Aug 10, 2026
a4b026c
Add method to list all staff users for a compact
landonshumway-ia Aug 10, 2026
4dae80a
Add method to deactivate staff user
landonshumway-ia Aug 10, 2026
47ad46f
Add logic/permission to re-enable user in re-invite flow
landonshumway-ia Aug 10, 2026
bbafb66
Add staff user directory class for finding compact/jurisdiction admins
landonshumway-ia Aug 11, 2026
80ae602
Add handler for checking inactive staff users
landonshumway-ia Aug 11, 2026
49f7ca7
Add email notification for staff user inactivity
landonshumway-ia Aug 11, 2026
85e4dfd
Add stack for processing staff user inactivity tracking
landonshumway-ia Aug 11, 2026
7b15a6a
Filter inactive admins from receiving notifications
landonshumway-ia Aug 11, 2026
ee975ca
Add error log alert for pre-token generation lambda
landonshumway-ia Aug 11, 2026
a8d5663
Send notification the last day before deactivation
landonshumway-ia Aug 12, 2026
22fec07
Do not send notification the day of deactivation
landonshumway-ia Aug 12, 2026
9488057
Fix comment
landonshumway-ia Aug 12, 2026
4335344
update dateOfUpdate when deactivating user
landonshumway-ia Aug 12, 2026
d3486b8
Add smoke tests for deactivation notifications
landonshumway-ia Aug 12, 2026
46b7fe5
Set dateOfUpdate when staff user permissions or attributes modified
landonshumway-ia Aug 13, 2026
42a737b
update nanoid version for node check
landonshumway-ia Aug 14, 2026
4ab179d
Bold date in email notification based on client feedback
landonshumway-ia Aug 18, 2026
6dc445b
Add error log alert for inactivity lambda
landonshumway-ia Aug 20, 2026
36a5af9
PR feedback - clarify comments and tests
landonshumway-ia Aug 20, 2026
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
6 changes: 3 additions & 3 deletions backend/compact-connect/lambdas/nodejs/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4674,9 +4674,9 @@ ms@^2.1.1, ms@^2.1.3:
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==

nanoid@^3.3.16:
version "3.3.16"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.16.tgz#a04d8ec4b1f10009d2d533947aefe4293737816c"
integrity sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==
version "3.3.18"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.18.tgz#f66a2de1199ffde0fcf21c8a5f13106b1c081913"
integrity sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==

napi-postinstall@^0.3.4:
version "0.3.4"
Expand Down
6 changes: 3 additions & 3 deletions backend/cosmetology-app/lambdas/nodejs/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4674,9 +4674,9 @@ ms@^2.1.1, ms@^2.1.3:
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==

nanoid@^3.3.16:
version "3.3.16"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.16.tgz#a04d8ec4b1f10009d2d533947aefe4293737816c"
integrity sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==
version "3.3.18"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.18.tgz#f66a2de1199ffde0fcf21c8a5f13106b1c081913"
integrity sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==

napi-postinstall@^0.3.4:
version "0.3.4"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,24 @@ export class Lambda implements LambdaInterface {
event.templateVariables.newJurisdiction
);
break;
case 'staffUserInactivityNotification':
if (!event.templateVariables?.staffUserFirstName
|| !event.templateVariables?.staffUserLastName
|| !event.templateVariables?.staffUserEmail
|| !event.templateVariables?.deactivationDate
|| !event.templateVariables?.inactivityPeriodDays) {
throw new Error('Missing required template variables for staffUserInactivityNotification template.');
}
await this.emailService.sendStaffUserInactivityNotificationEmail(
event.compact,
event.specificEmails || [],
event.templateVariables.staffUserFirstName,
event.templateVariables.staffUserLastName,
event.templateVariables.staffUserEmail,
event.templateVariables.deactivationDate,
event.templateVariables.inactivityPeriodDays
);
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 @@ -4,6 +4,15 @@ import { RecipientType } from '../models/email-notification-service-event';

const environmentVariableService = new EnvironmentVariablesService();

/** Format an ISO 8601 date string (YYYY-MM-DD) for display as MM/DD/YYYY (e.g. "09/14/2026"). Timezone-neutral. */
function formatIsoDateAsSlashFormat(isoDate: string): string {
const [year, month, day] = isoDate.split('-').map(Number);
const paddedMonth = String(month).padStart(2, '0');
const paddedDay = String(day).padStart(2, '0');

return `${paddedMonth}/${paddedDay}/${year}`;
}

/**
* Email service for handling email notifications
*/
Expand Down Expand Up @@ -77,4 +86,59 @@ export class EmailNotificationService extends BaseEmailService {

await this.sendEmail({ htmlContent, subject, recipients, errorMessage: 'Unable to send home jurisdiction change state notification email' });
}

/**
* Sends a notification that a staff user's account is scheduled for inactivity deactivation.
*
* The body is written in the third person so the same email serves the staff user and their
* administrators, and states an absolute date rather than a countdown so it stays true whenever it is read.
*
* @param compact - The compact name
* @param specificEmails - The address(es) to send this notification to
* @param staffUserFirstName - The affected staff user's first name
* @param staffUserLastName - The affected staff user's last name
* @param staffUserEmail - The affected staff user's email address
* @param deactivationDate - ISO 8601 date string (YYYY-MM-DD) the account is deactivated on
* @param inactivityPeriodDays - How many days of inactivity trigger deactivation
*/
public async sendStaffUserInactivityNotificationEmail(
compact: string,
specificEmails: string[],
staffUserFirstName: string,
staffUserLastName: string,
staffUserEmail: string,
deactivationDate: string,
inactivityPeriodDays: number
): Promise<void> {
this.logger.info('Sending staff user inactivity notification email', { compact: compact });

if (specificEmails.length === 0) {
throw new Error('No recipients found for staff user inactivity notification email');
}

const compactConfig = await this.compactConfigurationClient.getCompactConfiguration(compact);
const staffUserName = `${staffUserFirstName} ${staffUserLastName}`;
const deactivationDateDisplay = formatIsoDateAsSlashFormat(deactivationDate);
const subject = `CompactConnect account for ${staffUserName} will be deactivated on ${deactivationDateDisplay}`;

const report = this.getNewEmailTemplate();
const bodyText = `The ${compactConfig.compactName} CompactConnect account for ${staffUserName} (${staffUserEmail}) will be deactivated on **${deactivationDateDisplay}**. CompactConnect deactivates staff user accounts after ${inactivityPeriodDays} days with no sign-in activity.\n\n` +
`To prevent deactivation, ${staffUserName} should sign in to CompactConnect before **${deactivationDateDisplay}**.\n\n` +
`If the account has already been deactivated, an administrator will need to re-invite ${staffUserName} to CompactConnect to restore access.\n\n` +
`Sign in: ${environmentVariableService.getUiBasePathUrl()}/Dashboard`;

this.insertHeader(report, 'Account Deactivation Notice');
this.insertBody(report, bodyText, 'center', true);
this.insertFooter(report);

const htmlContent = this.renderTemplate(report);

await this.sendEmail({
htmlContent,
subject,
recipients: specificEmails,
errorMessage: 'Unable to send staff user inactivity notification email'
});
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -1168,4 +1168,58 @@ describe('EmailNotificationServiceLambda', () => {
.toThrow('Missing required template variables for home jurisdiction change notification template.');
});
});

describe('Staff User Inactivity Notification', () => {
const SAMPLE_STAFF_USER_INACTIVITY_NOTIFICATION_EVENT: EmailNotificationEvent = {
template: 'staffUserInactivityNotification',
recipientType: 'SPECIFIC',
compact: 'socw',
specificEmails: ['jane@example.com'],
templateVariables: {
staffUserFirstName: 'Jane',
staffUserLastName: 'Smith',
staffUserEmail: 'jane@example.com',
deactivationDate: '2026-09-14',
inactivityPeriodDays: 60
}
};

it('should successfully send staff user inactivity notification email', async () => {
mockDynamoDBClient.on(GetItemCommand).resolves({ Item: SAMPLE_COMPACT_CONFIGURATION });

const response = await lambda.handler(
SAMPLE_STAFF_USER_INACTIVITY_NOTIFICATION_EVENT,
{} as any
);

expect(response).toEqual({ message: 'Email message sent' });
expect(mockSESClient).toHaveReceivedCommandWith(SendEmailCommand, {
Destination: {
ToAddresses: ['jane@example.com']
}
});
});

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

await expect(lambda.handler(eventWithMissingVariables, {} as any))
.rejects
.toThrow('Missing required template variables for staffUserInactivityNotification template.');
});

it('should throw error when no recipients are provided', async () => {
const eventWithNoRecipients: EmailNotificationEvent = {
...SAMPLE_STAFF_USER_INACTIVITY_NOTIFICATION_EVENT,
specificEmails: []
};

await expect(lambda.handler(eventWithNoRecipients, {} as any))
.rejects
.toThrow('No recipients found for staff user inactivity notification email');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -201,4 +201,86 @@ describe('EmailNotificationService', () => {
)).rejects.toThrow('No recipients found for jurisdiction oh in compact aslp');
});
});

describe('Staff User Inactivity Notification', () => {
const sendInactivityNotification = (recipients: string[] = ['jane@example.com']) =>
emailService.sendStaffUserInactivityNotificationEmail(
'aslp',
recipients,
'Jane',
'Smith',
'jane@example.com',
'2026-09-14',
60
);

it('should send the notification with expected subject and content', async () => {
mockCompactConfigurationClient.getCompactConfiguration.mockResolvedValue(SAMPLE_COMPACT_CONFIG);

await sendInactivityNotification(['jane@example.com', 'admin@example.com']);

expect(mockSESClient).toHaveReceivedCommandWith(
SendEmailCommand,
{
Destination: {
ToAddresses: ['jane@example.com', 'admin@example.com']
},
Content: {
Simple: {
Body: {
Html: {
Charset: 'UTF-8',
Data: expect.stringContaining('<!DOCTYPE html>')
}
},
Subject: {
Charset: 'UTF-8',
Data: 'CompactConnect account for Jane Smith will be deactivated on 09/14/2026'
}
}
},
FromEmailAddress: 'CompactConnect <noreply@example.org>'
}
);
});

it('should render the deactivation date, the account it refers to, and the recovery path', async () => {
mockCompactConfigurationClient.getCompactConfiguration.mockResolvedValue(SAMPLE_COMPACT_CONFIG);

await sendInactivityNotification();

const emailCall = mockSESClient.commandCalls(SendEmailCommand)[0];
const htmlContent = emailCall.args[0].input.Content?.Simple?.Body?.Html?.Data;

expect(htmlContent).toBeDefined();
// Third person throughout, so the same body serves the user and their administrators
expect(htmlContent).toContain('Jane Smith');
expect(htmlContent).toContain('jane@example.com');
expect(htmlContent).toContain('09/14/2026');
expect(htmlContent).toContain('60 days');
expect(htmlContent).toContain('re-invite');
expect(htmlContent).toContain('https://app.test.compactconnect.org/Dashboard');
});

it('should render the deactivation date in bold everywhere it appears in the body', async () => {
mockCompactConfigurationClient.getCompactConfiguration.mockResolvedValue(SAMPLE_COMPACT_CONFIG);

await sendInactivityNotification();

const emailCall = mockSESClient.commandCalls(SendEmailCommand)[0];
const htmlContent = emailCall.args[0].input.Content?.Simple?.Body?.Html?.Data;

// The date appears twice in the body: once stating the deactivation date, once as the
// sign-in deadline. Both should be emphasized so the date doesn't get lost in the paragraph.
expect(htmlContent?.match(/<strong>09\/14\/2026<\/strong>/g)).toHaveLength(2);
});

it('should throw when there are no recipients', async () => {
mockCompactConfigurationClient.getCompactConfiguration.mockResolvedValue(SAMPLE_COMPACT_CONFIG);

await expect(sendInactivityNotification([]))
.rejects
.toThrow('No recipients found for staff user inactivity notification email');
});
});
});
6 changes: 3 additions & 3 deletions backend/social-work-app/lambdas/nodejs/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4674,9 +4674,9 @@ ms@^2.1.1, ms@^2.1.3:
integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==

nanoid@^3.3.16:
version "3.3.16"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.16.tgz#a04d8ec4b1f10009d2d533947aefe4293737816c"
integrity sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==
version "3.3.18"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.18.tgz#f66a2de1199ffde0fcf21c8a5f13106b1c081913"
integrity sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==

napi-postinstall@^0.3.4:
version "0.3.4"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# ruff: noqa: N802 we use camelCase to match the marshmallow schema definition

from datetime import datetime
from uuid import UUID

from cc_common.data_model.schema.common import CCDataClass, CCPermissionsAction
from cc_common.data_model.schema.user.record import UserRecordSchema


class StaffUserData(CCDataClass):
"""
Class representing a Staff User with getters for all properties.
"""

# Define record schema at the class level
_record_schema = UserRecordSchema()

# Require valid data when creating instances
_requires_data_at_construction = True

@property
def userId(self) -> UUID:
return self._data['userId']

@property
def compact(self) -> str:
return self._data['compact']

@property
def status(self) -> str:
return self._data['status']

@property
def lastLoginAt(self) -> datetime | None:
"""Absent for users who have not signed in since login tracking was introduced."""
return self._data.get('lastLoginAt')

# The attributes field is flattened here, since its nesting is a storage detail
@property
def email(self) -> str:
return self._data['attributes']['email']

@property
def givenName(self) -> str:
return self._data['attributes']['givenName']

@property
def familyName(self) -> str:
return self._data['attributes']['familyName']

@property
def compactActions(self) -> set[str]:
"""Compact-level actions. Empty if the user only has jurisdiction permissions."""
return self._data['permissions'].get('actions', set())

@property
def jurisdictions(self) -> set[str]:
"""Jurisdiction codes where this user holds any permission."""
return set(self._data['permissions']['jurisdictions'].keys())

def jurisdictionActions(self, jurisdiction: str) -> set[str]:
"""The user's actions in one jurisdiction. Empty if they hold no permissions there."""
return self._data['permissions']['jurisdictions'].get(jurisdiction, set())

@property
def isCompactAdmin(self) -> bool:
return CCPermissionsAction.ADMIN in self.compactActions

def isJurisdictionAdmin(self, jurisdiction: str) -> bool:
return CCPermissionsAction.ADMIN in self.jurisdictionActions(jurisdiction)
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ class UserAPISchema(Schema):
userId = Raw(required=True, allow_none=False)
status = String(required=True, allow_none=False, validate=OneOf([status.value for status in StaffUserStatus]))
dateOfUpdate = Raw(required=True, allow_none=False)
# Absent for users who have not signed in
lastLoginAt = Raw(required=False, allow_none=False)
attributes = Nested(UserAttributesAPISchema(), required=True, allow_none=False)
permissions = Dict(
keys=String(validate=OneOf(config.compacts)), # Key is one compact
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ruff: noqa: N801, N815 invalid-name
from marshmallow import Schema, post_dump, post_load, pre_dump
from marshmallow.fields import UUID, Dict, Nested, String
from marshmallow.fields import UUID, AwareDateTime, Dict, Nested, String
from marshmallow.validate import Length, OneOf

from cc_common.config import config
Expand Down Expand Up @@ -49,6 +49,9 @@ class UserRecordSchema(BaseRecordSchema):
compact = String(required=True, allow_none=False, validate=OneOf(config.compacts))
permissions = Nested(CompactPermissionsRecordSchema(), required=True, allow_none=False)
status = String(required=True, allow_none=False, validate=OneOf([status.value for status in StaffUserStatus]))
# Set by the pre-token-generation hook on every successful access token generation. Absent for users who
# have not signed in
Comment thread
jlkravitz marked this conversation as resolved.
lastLoginAt = AwareDateTime(required=False, allow_none=False)

# Generated fields
famGiv = String(required=True, allow_none=False)
Expand Down
Loading
Loading