Skip to content

Commit 5c13565

Browse files
authored
Merge pull request #2443 from trycompai/main
[comp] Production Deploy
2 parents 3580e94 + 13501bd commit 5c13565

50 files changed

Lines changed: 2822 additions & 551 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/api/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@
8383
"eslint": "^9.18.0",
8484
"eslint-config-prettier": "^10.0.1",
8585
"eslint-plugin-prettier": "^5.2.2",
86-
"globals": "^16.0.0",
86+
"globals": "^17.3.0",
8787
"jest": "^30.0.0",
8888
"prettier": "^3.5.3",
8989
"source-map-support": "^0.5.21",
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import {
2+
Body,
3+
Container,
4+
Font,
5+
Heading,
6+
Html,
7+
Preview,
8+
Section,
9+
Tailwind,
10+
Text,
11+
} from '@react-email/components';
12+
import { Footer } from '../components/footer';
13+
import { Logo } from '../components/logo';
14+
15+
interface Props {
16+
email: string;
17+
userName: string;
18+
organizationName: string;
19+
completedAt: Date;
20+
}
21+
22+
export const HipaaTrainingCompletedEmail = ({
23+
email,
24+
userName,
25+
organizationName,
26+
completedAt,
27+
}: Props) => {
28+
const formattedDate = new Date(completedAt).toLocaleDateString('en-US', {
29+
year: 'numeric',
30+
month: 'long',
31+
day: 'numeric',
32+
});
33+
34+
return (
35+
<Html>
36+
<Tailwind>
37+
<head>
38+
<Font
39+
fontFamily="Geist"
40+
fallbackFontFamily="Helvetica"
41+
fontWeight={400}
42+
fontStyle="normal"
43+
/>
44+
<Font
45+
fontFamily="Geist"
46+
fallbackFontFamily="Helvetica"
47+
fontWeight={500}
48+
fontStyle="normal"
49+
/>
50+
</head>
51+
<Preview>
52+
Congratulations! You've completed your HIPAA Security Awareness
53+
Training
54+
</Preview>
55+
56+
<Body className="mx-auto my-auto bg-[#fff] font-sans">
57+
<Container
58+
className="mx-auto my-[40px] max-w-[600px] border-transparent p-[20px] md:border-[#E8E7E1]"
59+
style={{ borderStyle: 'solid', borderWidth: 1 }}
60+
>
61+
<Logo />
62+
<Heading className="mx-0 my-[30px] p-0 text-center text-[24px] font-normal text-[#121212]">
63+
HIPAA Training Complete!
64+
</Heading>
65+
66+
<Text className="text-[14px] leading-[24px] text-[#121212]">
67+
Hi {userName},
68+
</Text>
69+
70+
<Text className="text-[14px] leading-[24px] text-[#121212]">
71+
Congratulations! You have successfully completed the HIPAA
72+
Security Awareness Training for{' '}
73+
<strong>{organizationName}</strong>.
74+
</Text>
75+
76+
<Section
77+
className="mt-[24px] mb-[24px] rounded-[8px] p-[24px] text-center"
78+
style={{
79+
backgroundColor: '#f0fdf4',
80+
border: '1px solid #bbf7d0',
81+
}}
82+
>
83+
<Text className="m-0 text-[16px] font-medium text-[#166534]">
84+
Completion Date: {formattedDate}
85+
</Text>
86+
</Section>
87+
88+
<Text className="text-[14px] leading-[24px] text-[#121212]">
89+
Your HIPAA training completion certificate is attached to this
90+
email. Please save it for your records.
91+
</Text>
92+
93+
<Text className="text-[14px] leading-[24px] text-[#121212]">
94+
Thank you for your commitment to protecting PHI and maintaining
95+
HIPAA compliance at {organizationName}.
96+
</Text>
97+
98+
<br />
99+
<Section>
100+
<Text className="text-[12px] leading-[24px] text-[#666666]">
101+
This notification was intended for{' '}
102+
<span className="text-[#121212]">{email}</span>.
103+
</Text>
104+
</Section>
105+
106+
<br />
107+
108+
<Footer />
109+
</Container>
110+
</Body>
111+
</Tailwind>
112+
</Html>
113+
);
114+
};
115+
116+
export default HipaaTrainingCompletedEmail;

apps/api/src/frameworks/frameworks-scores.helper.ts

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@ import { filterComplianceMembers } from '../utils/compliance-filters';
99

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

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

1415
export async function getOverviewScores(organizationId: string) {
15-
const [allPolicies, allTasks, employees, onboarding, org] = await Promise.all([
16+
const [allPolicies, allTasks, employees, onboarding, org, hipaaInstance] = await Promise.all([
1617
db.policy.findMany({ where: { organizationId } }),
1718
db.task.findMany({ where: { organizationId } }),
1819
db.member.findMany({
@@ -27,9 +28,14 @@ export async function getOverviewScores(organizationId: string) {
2728
where: { id: organizationId },
2829
select: { securityTrainingStepEnabled: true },
2930
}),
31+
db.frameworkInstance.findFirst({
32+
where: { organizationId, framework: { name: 'HIPAA' } },
33+
select: { id: true },
34+
}),
3035
]);
3136

3237
const securityTrainingStepEnabled = org?.securityTrainingStepEnabled === true;
38+
const hasHipaaFramework = !!hipaaInstance;
3339

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

63-
const trainingCompletions = securityTrainingStepEnabled
69+
const memberIds = activeEmployees.map((e) => e.id);
70+
const needsCompletions = securityTrainingStepEnabled || hasHipaaFramework;
71+
72+
const trainingCompletions = needsCompletions
6473
? await db.employeeTrainingVideoCompletion.findMany({
65-
where: { memberId: { in: activeEmployees.map((e) => e.id) } },
74+
where: { memberId: { in: memberIds } },
6675
})
6776
: [];
6877

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

83+
const completedVideoIds = trainingCompletions
84+
.filter((c) => c.memberId === emp.id && c.completedAt !== null)
85+
.map((c) => c.videoId);
86+
7487
const hasCompletedAllTraining = securityTrainingStepEnabled
75-
? (() => {
76-
const empCompletions = trainingCompletions.filter(
77-
(c) => c.memberId === emp.id,
78-
);
79-
const completedVideoIds = empCompletions
80-
.filter((c) => c.completedAt !== null)
81-
.map((c) => c.videoId);
82-
return TRAINING_VIDEO_IDS.every((vid) =>
83-
completedVideoIds.includes(vid),
84-
);
85-
})()
88+
? GENERAL_TRAINING_IDS.every((vid) => completedVideoIds.includes(vid))
89+
: true;
90+
91+
const hasCompletedHipaa = hasHipaaFramework
92+
? completedVideoIds.includes(HIPAA_TRAINING_ID)
8693
: true;
8794

88-
if (hasAcceptedAllPolicies && hasCompletedAllTraining) {
95+
if (hasAcceptedAllPolicies && hasCompletedAllTraining && hasCompletedHipaa) {
8996
completedMembers++;
9097
}
9198
}

apps/api/src/people/dto/people-responses.dto.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,12 @@ export class PeopleResponseDto {
111111
})
112112
isActive: boolean;
113113

114+
@ApiProperty({
115+
description: 'Whether member is deactivated',
116+
example: false,
117+
})
118+
deactivated: boolean;
119+
114120
@ApiProperty({
115121
description: 'FleetDM label ID for member devices',
116122
example: 123,

apps/api/src/people/people-invite.service.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -155,9 +155,8 @@ export class PeopleInviteService {
155155
isNewMember = true;
156156
}
157157

158-
// Create training video entries for new members
159158
if (member && isNewMember) {
160-
await this.createTrainingVideoEntries(member.id);
159+
await this.createTrainingVideoEntries(member.id, organizationId);
161160
}
162161

163162
// Send invite email (non-fatal)
@@ -280,10 +279,22 @@ export class PeopleInviteService {
280279
});
281280
}
282281

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

288+
if (organizationId) {
289+
const hipaaInstance = await db.frameworkInstance.findFirst({
290+
where: { organizationId, framework: { name: 'HIPAA' } },
291+
select: { id: true },
292+
});
293+
if (hipaaInstance) {
294+
trainingVideoIds.push('hipaa-sat-1');
295+
}
296+
}
297+
287298
await db.employeeTrainingVideoCompletion.createMany({
288299
data: trainingVideoIds.map((videoId) => ({
289300
memberId,

apps/api/src/people/people.controller.spec.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,17 @@ describe('PeopleController', () => {
103103
);
104104
});
105105

106+
it('should pass includeDeactivated=true to the service', async () => {
107+
mockPeopleService.findAllByOrganization.mockResolvedValue([]);
108+
109+
await controller.getAllPeople('org_123', mockAuthContext, 'true');
110+
111+
expect(peopleService.findAllByOrganization).toHaveBeenCalledWith(
112+
'org_123',
113+
true,
114+
);
115+
});
116+
106117
it('should not include authenticatedUser when userId is missing', async () => {
107118
const apiKeyContext: AuthContext = {
108119
...mockAuthContext,

apps/api/src/people/utils/member-queries.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export class MemberQueries {
1919
department: true,
2020
jobTitle: true,
2121
isActive: true,
22+
deactivated: true,
2223
fleetDmLabelId: true,
2324
user: {
2425
select: {

0 commit comments

Comments
 (0)