Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0",
"globals": "^17.3.0",
"jest": "^30.0.0",
"prettier": "^3.5.3",
"source-map-support": "^0.5.21",
Expand Down
116 changes: 116 additions & 0 deletions apps/api/src/email/templates/hipaa-training-completed.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import {
Body,
Container,
Font,
Heading,
Html,
Preview,
Section,
Tailwind,
Text,
} from '@react-email/components';
import { Footer } from '../components/footer';
import { Logo } from '../components/logo';

interface Props {
email: string;
userName: string;
organizationName: string;
completedAt: Date;
}

export const HipaaTrainingCompletedEmail = ({
email,
userName,
organizationName,
completedAt,
}: Props) => {
const formattedDate = new Date(completedAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});

return (
<Html>
<Tailwind>
<head>
<Font
fontFamily="Geist"
fallbackFontFamily="Helvetica"
fontWeight={400}
fontStyle="normal"
/>
<Font
fontFamily="Geist"
fallbackFontFamily="Helvetica"
fontWeight={500}
fontStyle="normal"
/>
</head>
<Preview>
Congratulations! You've completed your HIPAA Security Awareness
Training
</Preview>

<Body className="mx-auto my-auto bg-[#fff] font-sans">
<Container
className="mx-auto my-[40px] max-w-[600px] border-transparent p-[20px] md:border-[#E8E7E1]"
style={{ borderStyle: 'solid', borderWidth: 1 }}
>
<Logo />
<Heading className="mx-0 my-[30px] p-0 text-center text-[24px] font-normal text-[#121212]">
HIPAA Training Complete!
</Heading>

<Text className="text-[14px] leading-[24px] text-[#121212]">
Hi {userName},
</Text>

<Text className="text-[14px] leading-[24px] text-[#121212]">
Congratulations! You have successfully completed the HIPAA
Security Awareness Training for{' '}
<strong>{organizationName}</strong>.
</Text>

<Section
className="mt-[24px] mb-[24px] rounded-[8px] p-[24px] text-center"
style={{
backgroundColor: '#f0fdf4',
border: '1px solid #bbf7d0',
}}
>
<Text className="m-0 text-[16px] font-medium text-[#166534]">
Completion Date: {formattedDate}
</Text>
</Section>

<Text className="text-[14px] leading-[24px] text-[#121212]">
Your HIPAA training completion certificate is attached to this
email. Please save it for your records.
</Text>

<Text className="text-[14px] leading-[24px] text-[#121212]">
Thank you for your commitment to protecting PHI and maintaining
HIPAA compliance at {organizationName}.
</Text>

<br />
<Section>
<Text className="text-[12px] leading-[24px] text-[#666666]">
This notification was intended for{' '}
<span className="text-[#121212]">{email}</span>.
</Text>
</Section>

<br />

<Footer />
</Container>
</Body>
</Tailwind>
</Html>
);
};

export default HipaaTrainingCompletedEmail;
39 changes: 23 additions & 16 deletions apps/api/src/frameworks/frameworks-scores.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ import { filterComplianceMembers } from '../utils/compliance-filters';

const SIX_MONTHS_MS = 6 * 30 * 24 * 60 * 60 * 1000;

const TRAINING_VIDEO_IDS = ['sat-1', 'sat-2', 'sat-3', 'sat-4', 'sat-5'];
const GENERAL_TRAINING_IDS = ['sat-1', 'sat-2', 'sat-3', 'sat-4', 'sat-5'];
const HIPAA_TRAINING_ID = 'hipaa-sat-1';

export async function getOverviewScores(organizationId: string) {
const [allPolicies, allTasks, employees, onboarding, org] = await Promise.all([
const [allPolicies, allTasks, employees, onboarding, org, hipaaInstance] = await Promise.all([
db.policy.findMany({ where: { organizationId } }),
db.task.findMany({ where: { organizationId } }),
db.member.findMany({
Expand All @@ -27,9 +28,14 @@ export async function getOverviewScores(organizationId: string) {
where: { id: organizationId },
select: { securityTrainingStepEnabled: true },
}),
db.frameworkInstance.findFirst({
where: { organizationId, framework: { name: 'HIPAA' } },
select: { id: true },
}),
]);

const securityTrainingStepEnabled = org?.securityTrainingStepEnabled === true;
const hasHipaaFramework = !!hipaaInstance;

// Policy breakdown
const publishedPolicies = allPolicies.filter((p) => p.status === 'published');
Expand Down Expand Up @@ -60,9 +66,12 @@ export async function getOverviewScores(organizationId: string) {
p.isRequiredToSign && p.status === 'published' && !p.isArchived,
);

const trainingCompletions = securityTrainingStepEnabled
const memberIds = activeEmployees.map((e) => e.id);
const needsCompletions = securityTrainingStepEnabled || hasHipaaFramework;

const trainingCompletions = needsCompletions
? await db.employeeTrainingVideoCompletion.findMany({
where: { memberId: { in: activeEmployees.map((e) => e.id) } },
where: { memberId: { in: memberIds } },
})
: [];

Expand All @@ -71,21 +80,19 @@ export async function getOverviewScores(organizationId: string) {
requiredPolicies.length === 0 ||
requiredPolicies.every((p) => p.signedBy.includes(emp.id));

const completedVideoIds = trainingCompletions
.filter((c) => c.memberId === emp.id && c.completedAt !== null)
.map((c) => c.videoId);

const hasCompletedAllTraining = securityTrainingStepEnabled
? (() => {
const empCompletions = trainingCompletions.filter(
(c) => c.memberId === emp.id,
);
const completedVideoIds = empCompletions
.filter((c) => c.completedAt !== null)
.map((c) => c.videoId);
return TRAINING_VIDEO_IDS.every((vid) =>
completedVideoIds.includes(vid),
);
})()
? GENERAL_TRAINING_IDS.every((vid) => completedVideoIds.includes(vid))
: true;

const hasCompletedHipaa = hasHipaaFramework
? completedVideoIds.includes(HIPAA_TRAINING_ID)
: true;

if (hasAcceptedAllPolicies && hasCompletedAllTraining) {
if (hasAcceptedAllPolicies && hasCompletedAllTraining && hasCompletedHipaa) {
completedMembers++;
}
}
Expand Down
6 changes: 6 additions & 0 deletions apps/api/src/people/dto/people-responses.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,12 @@ export class PeopleResponseDto {
})
isActive: boolean;

@ApiProperty({
description: 'Whether member is deactivated',
example: false,
})
deactivated: boolean;

@ApiProperty({
description: 'FleetDM label ID for member devices',
example: 123,
Expand Down
19 changes: 15 additions & 4 deletions apps/api/src/people/people-invite.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,8 @@ export class PeopleInviteService {
isNewMember = true;
}

// Create training video entries for new members
if (member && isNewMember) {
await this.createTrainingVideoEntries(member.id);
await this.createTrainingVideoEntries(member.id, organizationId);
}

// Send invite email (non-fatal)
Expand Down Expand Up @@ -280,10 +279,22 @@ export class PeopleInviteService {
});
}

private async createTrainingVideoEntries(memberId: string): Promise<void> {
// Training videos are defined in the app; we create entries for known video IDs
private async createTrainingVideoEntries(
memberId: string,
organizationId?: string,
): Promise<void> {
const trainingVideoIds = ['sat-1', 'sat-2', 'sat-3', 'sat-4', 'sat-5'];

if (organizationId) {
const hipaaInstance = await db.frameworkInstance.findFirst({
where: { organizationId, framework: { name: 'HIPAA' } },
select: { id: true },
});
if (hipaaInstance) {
trainingVideoIds.push('hipaa-sat-1');
}
}

await db.employeeTrainingVideoCompletion.createMany({
data: trainingVideoIds.map((videoId) => ({
memberId,
Expand Down
11 changes: 11 additions & 0 deletions apps/api/src/people/people.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,17 @@ describe('PeopleController', () => {
);
});

it('should pass includeDeactivated=true to the service', async () => {
mockPeopleService.findAllByOrganization.mockResolvedValue([]);

await controller.getAllPeople('org_123', mockAuthContext, 'true');

expect(peopleService.findAllByOrganization).toHaveBeenCalledWith(
'org_123',
true,
);
});

it('should not include authenticatedUser when userId is missing', async () => {
const apiKeyContext: AuthContext = {
...mockAuthContext,
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/people/utils/member-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export class MemberQueries {
department: true,
jobTitle: true,
isActive: true,
deactivated: true,
fleetDmLabelId: true,
user: {
select: {
Expand Down
Loading
Loading