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
24 changes: 18 additions & 6 deletions apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import { MultiRoleCombobox } from './MultiRoleCombobox';
import { RemoveDeviceAlert } from './RemoveDeviceAlert';
import { RemoveMemberAlert } from './RemoveMemberAlert';
import type { CustomRoleOption } from './MultiRoleCombobox';
import type { MemberWithUser } from './TeamMembers';
import type { MemberWithUser, TaskCompletion } from './TeamMembers';

interface MemberRowProps {
member: MemberWithUser;
Expand All @@ -50,7 +50,7 @@ interface MemberRowProps {
canEdit: boolean;
isCurrentUserOwner: boolean;
customRoles?: CustomRoleOption[];
taskCompletion?: { completed: number; total: number };
taskCompletion?: TaskCompletion;
hasDeviceAgentDevice?: boolean;
}

Expand Down Expand Up @@ -254,16 +254,28 @@ export function MemberRow({
{/* TASKS */}
<TableCell>
{taskCompletion ? (
<div className="w-[170px]">
<div className="w-[220px]">
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full bg-primary transition-all"
style={{ width: `${taskProgressPercent}%` }}
/>
</div>
<Text size="xs" variant="muted">
{taskCompletion.completed}/{taskCompletion.total} complete
</Text>
<div className="mt-1 flex flex-wrap gap-x-2 gap-y-0.5">
<Text size="xs" variant="muted">
Policies {taskCompletion.policies.completed}/{taskCompletion.policies.total}
</Text>
{taskCompletion.training.total > 0 && (
<Text size="xs" variant="muted">
Training {taskCompletion.training.completed}/{taskCompletion.training.total}
</Text>
)}
{taskCompletion.hipaa && (
<Text size="xs" variant="muted">
HIPAA {taskCompletion.hipaa.completed}/{taskCompletion.hipaa.total}
</Text>
)}
</div>
</div>
) : (
<Text size="sm" variant="muted">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { filterComplianceMembers } from '@/lib/compliance';
import { HIPAA_TRAINING_ID } from '@/lib/data/hipaa-training-content';
import { trainingVideos as trainingVideosData } from '@/lib/data/training-videos';
import { serverApi } from '@/lib/server-api-client';
import type { Invitation, Member, User } from '@db';
Expand All @@ -15,6 +16,14 @@ export interface TeamMembersData {
pendingInvitations: Invitation[];
}

export interface TaskCompletion {
completed: number;
total: number;
policies: { completed: number; total: number };
training: { completed: number; total: number };
hipaa?: { completed: number; total: number };
}

export interface TeamMembersProps {
canManageMembers: boolean;
canInviteUsers: boolean;
Expand Down Expand Up @@ -51,7 +60,7 @@ export async function TeamMembers(props: TeamMembersProps) {
const employeeSyncData = await getEmployeeSyncConnections(organizationId);

// Build task completion map for employees/contractors
const taskCompletionMap: Record<string, { completed: number; total: number }> = {};
const taskCompletionMap: Record<string, TaskCompletion> = {};

const employeeMembers = await filterComplianceMembers(members, organizationId);

Expand All @@ -69,10 +78,17 @@ export async function TeamMembers(props: TeamMembersProps) {
];

if (employeeMembers.length > 0) {
const org = await db.organization.findUnique({
where: { id: organizationId },
select: { securityTrainingStepEnabled: true },
});
const [org, hipaaInstance] = await Promise.all([
db.organization.findUnique({
where: { id: organizationId },
select: { securityTrainingStepEnabled: true },
}),
db.frameworkInstance.findFirst({
where: { organizationId, framework: { name: 'HIPAA' } },
select: { id: true },
}),
]);
const hasHipaaFramework = !!hipaaInstance;

const policies = await db.policy.findMany({
where: {
Expand All @@ -92,20 +108,40 @@ export async function TeamMembers(props: TeamMembersProps) {

const totalPolicies = policies.length;
const totalTrainingVideos = org?.securityTrainingStepEnabled ? trainingVideosData.length : 0;
const totalTasks = totalPolicies + totalTrainingVideos;
const totalHipaaTraining = hasHipaaFramework ? 1 : 0;
const totalTasks = totalPolicies + totalTrainingVideos + totalHipaaTraining;

for (const employee of employeeMembers) {
const policiesCompleted = policies.filter((p) => p.signedBy.includes(employee.id)).length;

const trainingsCompleted = org?.securityTrainingStepEnabled
? trainingCompletions.filter(
(tc) => tc.memberId === employee.id && tc.completedAt !== null,
(tc) =>
tc.memberId === employee.id &&
tc.completedAt !== null &&
tc.videoId !== HIPAA_TRAINING_ID,
).length
: 0;

const hipaaCompleted =
hasHipaaFramework &&
trainingCompletions.some(
(tc) =>
tc.memberId === employee.id &&
tc.videoId === HIPAA_TRAINING_ID &&
tc.completedAt !== null,
)
? 1
: 0;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HIPAA completion undetectable when security training disabled

High Severity

When securityTrainingStepEnabled is false but the org has a HIPAA framework, trainingCompletions is set to an empty array (line 103–107). The hipaaCompleted check (line 126–135) searches this empty array, so it always returns 0. However, totalHipaaTraining is correctly set to 1 based on hasHipaaFramework alone. This means HIPAA tasks appear in the total but can never be marked completed — employees will always look incomplete. The dashboard's EmployeesOverview correctly fetches HIPAA completions in a separate query, but this component doesn't.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b4e1d1e. Configure here.


taskCompletionMap[employee.id] = {
completed: policiesCompleted + trainingsCompleted,
completed: policiesCompleted + trainingsCompleted + hipaaCompleted,
total: totalTasks,
policies: { completed: policiesCompleted, total: totalPolicies },
training: { completed: trainingsCompleted, total: totalTrainingVideos },
...(hasHipaaFramework && {
hipaa: { completed: hipaaCompleted, total: 1 },
}),
};
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import { apiClient } from '@/lib/api-client';
import { buildDisplayItems, filterDisplayItems } from './filter-members';
import { MemberRow } from './MemberRow';
import { PendingInvitationRow } from './PendingInvitationRow';
import type { MemberWithUser, TeamMembersData } from './TeamMembers';
import type { MemberWithUser, TaskCompletion, TeamMembersData } from './TeamMembers';

import type { EmployeeSyncConnectionsData } from '../data/queries';
import { useEmployeeSync } from '../hooks/useEmployeeSync';
Expand All @@ -52,7 +52,7 @@ interface TeamMembersClientProps {
isAuditor: boolean;
isCurrentUserOwner: boolean;
employeeSyncData: EmployeeSyncConnectionsData;
taskCompletionMap: Record<string, { completed: number; total: number }>;
taskCompletionMap: Record<string, TaskCompletion>;
memberIdsWithDeviceAgent: string[];
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ export function EmployeeCompletionChart({
}

function TaskBarChart({ stat }: { stat: EmployeeTaskStats }) {
const totalCompleted = stat.policiesCompleted + stat.trainingsCompleted;
const totalCompleted = stat.policiesCompleted + stat.trainingsCompleted + (stat.hipaaCompleted ? 1 : 0);
const totalIncomplete = stat.totalTasks - totalCompleted;
const barHeight = 12;

Expand Down
Loading