diff --git a/apps/api/src/auth/hybrid-auth.guard.ts b/apps/api/src/auth/hybrid-auth.guard.ts index 0e027e873..91441bc71 100644 --- a/apps/api/src/auth/hybrid-auth.guard.ts +++ b/apps/api/src/auth/hybrid-auth.guard.ts @@ -232,6 +232,7 @@ export class HybridAuthGuard implements CanActivate { where: { userId, organizationId, + deactivated: false, }, select: { id: true, diff --git a/apps/api/src/comments/comments.service.ts b/apps/api/src/comments/comments.service.ts index 7d245dd8b..864ce6774 100644 --- a/apps/api/src/comments/comments.service.ts +++ b/apps/api/src/comments/comments.service.ts @@ -116,6 +116,7 @@ export class CommentsService { name: comment.author.user.name, email: comment.author.user.email, image: comment.author.user.image, + deactivated: comment.author.deactivated, }, attachments, createdAt: comment.createdAt, @@ -154,6 +155,7 @@ export class CommentsService { where: { userId, organizationId, + deactivated: false, }, include: { user: true, @@ -211,6 +213,7 @@ export class CommentsService { name: member.user.name, email: member.user.email, image: member.user.image, + deactivated: member.deactivated, }, attachments: result.attachments, createdAt: result.comment.createdAt, @@ -284,6 +287,7 @@ export class CommentsService { name: existingComment.author.user.name, email: existingComment.author.user.email, image: existingComment.author.user.image, + deactivated: existingComment.author.deactivated, }, attachments, createdAt: updatedComment.createdAt, diff --git a/apps/api/src/comments/dto/comment-responses.dto.ts b/apps/api/src/comments/dto/comment-responses.dto.ts index e98f366ce..dcf7ddcd0 100644 --- a/apps/api/src/comments/dto/comment-responses.dto.ts +++ b/apps/api/src/comments/dto/comment-responses.dto.ts @@ -89,6 +89,13 @@ export class AuthorResponseDto { nullable: true, }) image: string | null; + + @ApiProperty({ + description: 'Whether the user is deactivated', + example: false, + nullable: true, + }) + deactivated: boolean; } export class CommentResponseDto { diff --git a/apps/api/src/devices/devices.service.ts b/apps/api/src/devices/devices.service.ts index 0bbaa4307..0d977c9f0 100644 --- a/apps/api/src/devices/devices.service.ts +++ b/apps/api/src/devices/devices.service.ts @@ -97,6 +97,7 @@ export class DevicesService { where: { id: memberId, organizationId: organizationId, + deactivated: false, }, select: { id: true, @@ -165,6 +166,7 @@ export class DevicesService { where: { id: memberId, organizationId: organizationId, + deactivated: false, }, select: { id: true, diff --git a/apps/api/src/people/utils/member-queries.ts b/apps/api/src/people/utils/member-queries.ts index 0985195c5..37c019e65 100644 --- a/apps/api/src/people/utils/member-queries.ts +++ b/apps/api/src/people/utils/member-queries.ts @@ -40,7 +40,7 @@ export class MemberQueries { organizationId: string, ): Promise { return db.member.findMany({ - where: { organizationId }, + where: { organizationId, deactivated: false }, select: this.MEMBER_SELECT, orderBy: { createdAt: 'desc' }, }); diff --git a/apps/api/src/people/utils/member-validator.ts b/apps/api/src/people/utils/member-validator.ts index 1c678c63f..8236990ae 100644 --- a/apps/api/src/people/utils/member-validator.ts +++ b/apps/api/src/people/utils/member-validator.ts @@ -47,6 +47,7 @@ export class MemberValidator { where: { id: memberId, organizationId, + deactivated: false, }, select: { id: true, userId: true }, }); @@ -71,6 +72,7 @@ export class MemberValidator { const whereClause: any = { userId, organizationId, + deactivated: false, }; if (excludeMemberId) { diff --git a/apps/app/src/actions/add-comment.ts b/apps/app/src/actions/add-comment.ts index 40a92dbf7..0ef626f17 100644 --- a/apps/app/src/actions/add-comment.ts +++ b/apps/app/src/actions/add-comment.ts @@ -38,6 +38,7 @@ export const addCommentAction = authActionClient where: { userId: session.userId, organizationId: session.activeOrganizationId, + deactivated: false, }, }); diff --git a/apps/app/src/actions/change-organization.ts b/apps/app/src/actions/change-organization.ts index 32e768bb0..9dc48ea53 100644 --- a/apps/app/src/actions/change-organization.ts +++ b/apps/app/src/actions/change-organization.ts @@ -28,6 +28,7 @@ export const changeOrganizationAction = authActionClient where: { userId: user.id, organizationId, + deactivated: false, }, }); diff --git a/apps/app/src/actions/organization/accept-invitation.ts b/apps/app/src/actions/organization/accept-invitation.ts index 78e0bf7ac..a507fbf6d 100644 --- a/apps/app/src/actions/organization/accept-invitation.ts +++ b/apps/app/src/actions/organization/accept-invitation.ts @@ -69,6 +69,7 @@ export const completeInvitation = authActionClientWithoutOrg where: { userId: user.id, organizationId: invitation.organizationId, + deactivated: false, }, }); diff --git a/apps/app/src/actions/organization/get-organization-users-action.ts b/apps/app/src/actions/organization/get-organization-users-action.ts index dcd33e6a8..aaa9d2e49 100644 --- a/apps/app/src/actions/organization/get-organization-users-action.ts +++ b/apps/app/src/actions/organization/get-organization-users-action.ts @@ -26,6 +26,7 @@ export const getOrganizationUsersAction = authActionClient const users = await db.member.findMany({ where: { organizationId: ctx.session.activeOrganizationId, + deactivated: false, }, select: { user: { diff --git a/apps/app/src/actions/policies/accept-requested-policy-changes.ts b/apps/app/src/actions/policies/accept-requested-policy-changes.ts index f60e30acc..a3d716756 100644 --- a/apps/app/src/actions/policies/accept-requested-policy-changes.ts +++ b/apps/app/src/actions/policies/accept-requested-policy-changes.ts @@ -82,6 +82,7 @@ export const acceptRequestedPolicyChangesAction = authActionClient where: { organizationId: session.activeOrganizationId, isActive: true, + deactivated: false, }, include: { user: true, @@ -131,6 +132,7 @@ export const acceptRequestedPolicyChangesAction = authActionClient where: { userId: user.id, organizationId: session.activeOrganizationId, + deactivated: false, }, }); diff --git a/apps/app/src/actions/policies/create-new-policy.ts b/apps/app/src/actions/policies/create-new-policy.ts index 9f7e75a82..146528fb6 100644 --- a/apps/app/src/actions/policies/create-new-policy.ts +++ b/apps/app/src/actions/policies/create-new-policy.ts @@ -39,6 +39,7 @@ export const createPolicyAction = authActionClient where: { userId: user.id, organizationId: activeOrganizationId, + deactivated: false, }, }); diff --git a/apps/app/src/actions/policies/deny-requested-policy-changes.ts b/apps/app/src/actions/policies/deny-requested-policy-changes.ts index 5c937edbe..a61dcec38 100644 --- a/apps/app/src/actions/policies/deny-requested-policy-changes.ts +++ b/apps/app/src/actions/policies/deny-requested-policy-changes.ts @@ -68,6 +68,7 @@ export const denyRequestedPolicyChangesAction = authActionClient where: { userId: user.id, organizationId: session.activeOrganizationId, + deactivated: false, }, }); diff --git a/apps/app/src/actions/policies/publish-all.ts b/apps/app/src/actions/policies/publish-all.ts index 8ce0cdf79..8a17e6c51 100644 --- a/apps/app/src/actions/policies/publish-all.ts +++ b/apps/app/src/actions/policies/publish-all.ts @@ -41,6 +41,7 @@ export const publishAllPoliciesAction = authActionClient where: { userId: user.id, organizationId: parsedInput.organizationId, + deactivated: false, }, }); @@ -104,6 +105,7 @@ export const publishAllPoliciesAction = authActionClient where: { organizationId: parsedInput.organizationId, isActive: true, + deactivated: false, OR: [ { role: { contains: Role.employee } }, { role: { contains: Role.contractor } }, diff --git a/apps/app/src/actions/safe-action.ts b/apps/app/src/actions/safe-action.ts index 656010ebb..49280a4ea 100644 --- a/apps/app/src/actions/safe-action.ts +++ b/apps/app/src/actions/safe-action.ts @@ -231,6 +231,7 @@ export const authWithOrgAccessClient = authActionClient.use(async ({ next, clien where: { userId: ctx.user.id, organizationId, + deactivated: false, }, }); diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/page.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/page.tsx index d26062890..7c87b44eb 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/page.tsx @@ -39,6 +39,7 @@ export default async function DashboardPage({ params }: { params: Promise<{ orgI where: { userId: session.user.id, organizationId, + deactivated: false, }, }); diff --git a/apps/app/src/app/(app)/[orgId]/layout.tsx b/apps/app/src/app/(app)/[orgId]/layout.tsx index c54c4ac87..50cc6268c 100644 --- a/apps/app/src/app/(app)/[orgId]/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/layout.tsx @@ -56,6 +56,7 @@ export default async function Layout({ where: { userId: session.user.id, organizationId: requestedOrgId, + deactivated: false, }, }); diff --git a/apps/app/src/app/(app)/[orgId]/people/all/actions/addEmployeeWithoutInvite.ts b/apps/app/src/app/(app)/[orgId]/people/all/actions/addEmployeeWithoutInvite.ts index 5ee3eae37..66d1da59d 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/actions/addEmployeeWithoutInvite.ts +++ b/apps/app/src/app/(app)/[orgId]/people/all/actions/addEmployeeWithoutInvite.ts @@ -25,6 +25,7 @@ export const addEmployeeWithoutInvite = async ({ where: { organizationId: organizationId, userId: currentUserId, + deactivated: false, }, }); @@ -57,16 +58,45 @@ export const addEmployeeWithoutInvite = async ({ userId = newUser.id; } - const member = await auth.api.addMember({ - body: { - userId: existingUser?.id ?? userId, + const finalUserId = existingUser?.id ?? userId; + + // Check if there's an existing member (including deactivated ones) for this user and organization + const existingMember = await db.member.findFirst({ + where: { + userId: finalUserId, organizationId, - role: roles, // Auth API expects role or role array }, }); - // Create training video completion entries for the new member - if (member?.id) { + let member; + if (existingMember) { + // If member exists but is deactivated, reactivate it and update roles + if (existingMember.deactivated) { + const roleString = roles.sort().join(','); + member = await db.member.update({ + where: { id: existingMember.id }, + data: { + deactivated: false, + role: roleString, + }, + }); + } else { + // Member already exists and is active, return existing member + member = existingMember; + } + } else { + // No existing member, create a new one + member = await auth.api.addMember({ + body: { + userId: finalUserId, + organizationId, + role: roles, // Auth API expects role or role array + }, + }); + } + + // Create training video completion entries for the new member (only if member was just created/reactivated) + if (member?.id && !existingMember) { await createTrainingVideoEntries(member.id); } diff --git a/apps/app/src/app/(app)/[orgId]/people/all/actions/removeMember.ts b/apps/app/src/app/(app)/[orgId]/people/all/actions/removeMember.ts index a734a9124..822eef671 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/actions/removeMember.ts +++ b/apps/app/src/app/(app)/[orgId]/people/all/actions/removeMember.ts @@ -6,6 +6,7 @@ import { z } from 'zod'; // Adjust safe-action import for colocalized structure import { authActionClient } from '@/actions/safe-action'; import type { ActionResponse } from '@/actions/types'; +import { sendUnassignedItemsNotificationEmail, type UnassignedItem } from '@comp/email'; const removeMemberSchema = z.object({ memberId: z.string(), @@ -36,6 +37,7 @@ export const removeMember = authActionClient where: { organizationId: ctx.session.activeOrganizationId, userId: ctx.user.id, + deactivated: false, }, }); @@ -55,6 +57,9 @@ export const removeMember = authActionClient id: memberId, organizationId: ctx.session.activeOrganizationId, }, + include: { + user: true, + }, }); if (!targetMember) { @@ -80,11 +85,148 @@ export const removeMember = authActionClient }; } - // Remove the member - await db.member.delete({ + // Get organization name + const organization = await db.organization.findUnique({ + where: { + id: ctx.session.activeOrganizationId, + }, + select: { + name: true, + }, + }); + + // Check for assignments and collect unassigned items + const unassignedItems: UnassignedItem[] = []; + + // Check tasks + const assignedTasks = await db.task.findMany({ + where: { + assigneeId: memberId, + organizationId: ctx.session.activeOrganizationId, + }, + select: { + id: true, + title: true, + }, + }); + + for (const task of assignedTasks) { + unassignedItems.push({ + type: 'task', + id: task.id, + name: task.title, + }); + } + + // Check policies + const assignedPolicies = await db.policy.findMany({ + where: { + assigneeId: memberId, + organizationId: ctx.session.activeOrganizationId, + }, + select: { + id: true, + name: true, + }, + }); + + for (const policy of assignedPolicies) { + unassignedItems.push({ + type: 'policy', + id: policy.id, + name: policy.name, + }); + } + + // Check risks + const assignedRisks = await db.risk.findMany({ + where: { + assigneeId: memberId, + organizationId: ctx.session.activeOrganizationId, + }, + select: { + id: true, + title: true, + }, + }); + + for (const risk of assignedRisks) { + unassignedItems.push({ + type: 'risk', + id: risk.id, + name: risk.title, + }); + } + + // Check vendors + const assignedVendors = await db.vendor.findMany({ + where: { + assigneeId: memberId, + organizationId: ctx.session.activeOrganizationId, + }, + select: { + id: true, + name: true, + }, + }); + + for (const vendor of assignedVendors) { + unassignedItems.push({ + type: 'vendor', + id: vendor.id, + name: vendor.name, + }); + } + + // Clear all assignments + await Promise.all([ + db.task.updateMany({ + where: { + assigneeId: memberId, + organizationId: ctx.session.activeOrganizationId, + }, + data: { + assigneeId: null, + }, + }), + db.policy.updateMany({ + where: { + assigneeId: memberId, + organizationId: ctx.session.activeOrganizationId, + }, + data: { + assigneeId: null, + }, + }), + db.risk.updateMany({ + where: { + assigneeId: memberId, + organizationId: ctx.session.activeOrganizationId, + }, + data: { + assigneeId: null, + }, + }), + db.vendor.updateMany({ + where: { + assigneeId: memberId, + organizationId: ctx.session.activeOrganizationId, + }, + data: { + assigneeId: null, + }, + }), + ]); + + // Mark the member as deactivated instead of deleting + await db.member.update({ where: { id: memberId, }, + data: { + deactivated: true, + isActive: false, + }, }); // Consider if deleting sessions is still desired here @@ -94,6 +236,34 @@ export const removeMember = authActionClient }, }); + // Notify admins if there are unassigned items + if (unassignedItems.length > 0 && organization) { + const owner = await db.member.findFirst({ + where: { + organizationId: ctx.session.activeOrganizationId, + role: { contains: 'owner' }, + deactivated: false, + }, + include: { + user: true, + }, + }); + + const removedMemberName = targetMember.user.name || targetMember.user.email || 'Member'; + + if (owner) { + // Send email to the org owner + sendUnassignedItemsNotificationEmail({ + email: owner.user.email, + userName: owner.user.name || owner.user.email || 'Owner', + organizationName: organization.name, + organizationId: ctx.session.activeOrganizationId, + removedMemberName, + unassignedItems, + }); + } + } + revalidatePath(`/${ctx.session.activeOrganizationId}/settings/users`); revalidateTag(`user_${ctx.user.id}`); diff --git a/apps/app/src/app/(app)/[orgId]/people/all/actions/revokeInvitation.ts b/apps/app/src/app/(app)/[orgId]/people/all/actions/revokeInvitation.ts index 8558f1805..d826135a4 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/actions/revokeInvitation.ts +++ b/apps/app/src/app/(app)/[orgId]/people/all/actions/revokeInvitation.ts @@ -37,6 +37,7 @@ export const revokeInvitation = authActionClient where: { organizationId: ctx.session.activeOrganizationId, userId: ctx.user.id, + deactivated: false, }, }); diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembers.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembers.tsx index fdeff7173..ed20bda8b 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembers.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembers.tsx @@ -45,6 +45,7 @@ export async function TeamMembers() { const fetchedMembers = await db.member.findMany({ where: { organizationId: organizationId, + deactivated: false, }, include: { user: true, diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx index 358470b3a..545542d8f 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx @@ -140,7 +140,7 @@ export function TeamMembersClient({ const handleRemoveMember = async (memberId: string) => { const result = await removeMemberAction({ memberId }); - if (result?.data) { + if (result?.data?.success) { // Success case toast.success('has been removed from the organization'); router.refresh(); // Add client-side refresh as well diff --git a/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeesOverview.tsx b/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeesOverview.tsx index 09a5cd1c5..ca9537d3c 100644 --- a/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeesOverview.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeesOverview.tsx @@ -41,6 +41,7 @@ export async function EmployeesOverview() { const fetchedMembers = await db.member.findMany({ where: { organizationId: organizationId, + deactivated: false, }, include: { user: true, diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/data/index.ts b/apps/app/src/app/(app)/[orgId]/people/devices/data/index.ts index 3ec1d54f5..965348591 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/data/index.ts +++ b/apps/app/src/app/(app)/[orgId]/people/devices/data/index.ts @@ -23,6 +23,7 @@ export const getEmployeeDevices: () => Promise = async () => { const employees = await db.member.findMany({ where: { organizationId, + deactivated: false, }, }); diff --git a/apps/app/src/app/(app)/[orgId]/people/layout.tsx b/apps/app/src/app/(app)/[orgId]/people/layout.tsx index 149b80caf..44640914e 100644 --- a/apps/app/src/app/(app)/[orgId]/people/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/layout.tsx @@ -18,6 +18,7 @@ export default async function Layout({ children }: { children: React.ReactNode } const allMembers = await db.member.findMany({ where: { organizationId: orgId, + deactivated: false, }, }); diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/RecentAuditLogs.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/RecentAuditLogs.tsx index f9af8cb7b..0a78bb0cd 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/RecentAuditLogs.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/RecentAuditLogs.tsx @@ -3,10 +3,17 @@ import { Badge } from '@comp/ui/badge'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@comp/ui/card'; import { cn } from '@comp/ui/cn'; import { ScrollArea } from '@comp/ui/scroll-area'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@comp/ui/tooltip'; import { AuditLog, AuditLogEntityType } from '@db'; import { format } from 'date-fns'; import { ActivityIcon, + AlertTriangle, CalendarIcon, ClockIcon, FileIcon, @@ -90,6 +97,7 @@ const getUserInfo = (log: AuditLogWithRelations) => { name: log.user.name, email: log.user.email, avatarUrl: log.user.image || undefined, + deactivated: log.member?.deactivated || false, }; } @@ -98,6 +106,7 @@ const getUserInfo = (log: AuditLogWithRelations) => { name: undefined, email: undefined, avatarUrl: undefined, + deactivated: false, }; }; @@ -110,10 +119,26 @@ const LogItem = ({ log }: { log: AuditLogWithRelations }) => {
- - - {getInitials(userInfo.name)} - +
+ + + {getInitials(userInfo.name)} + + {userInfo.deactivated && ( + + + +
+ +
+
+ +

This user is deactivated.

+
+
+
+ )} +
diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/data/index.ts b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/data/index.ts index 314c0cb43..2c9bb3a50 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/data/index.ts +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/data/index.ts @@ -137,6 +137,7 @@ export const getAssignees = async () => { role: { notIn: ['employee', 'contractor'], }, + deactivated: false, }, include: { user: true, @@ -193,6 +194,7 @@ export const getComments = async (policyId: string): Promise ({ id: att.id, diff --git a/apps/app/src/app/(app)/[orgId]/risk/(overview)/page.tsx b/apps/app/src/app/(app)/[orgId]/risk/(overview)/page.tsx index 7273c18c7..175ce7e03 100644 --- a/apps/app/src/app/(app)/[orgId]/risk/(overview)/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/risk/(overview)/page.tsx @@ -116,6 +116,7 @@ const getAssignees = cache(async () => { where: { organizationId: session.session.activeOrganizationId, isActive: true, + deactivated: false, role: { notIn: ['employee', 'contractor'], }, diff --git a/apps/app/src/app/(app)/[orgId]/risk/[riskId]/page.tsx b/apps/app/src/app/(app)/[orgId]/risk/[riskId]/page.tsx index 7ad8e795e..5fe1a4560 100644 --- a/apps/app/src/app/(app)/[orgId]/risk/[riskId]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/risk/[riskId]/page.tsx @@ -91,6 +91,7 @@ const getAssignees = cache(async () => { role: { notIn: ['employee', 'contractor'], }, + deactivated: false, }, include: { user: true, diff --git a/apps/app/src/app/(app)/[orgId]/tasks/page.tsx b/apps/app/src/app/(app)/[orgId]/tasks/page.tsx index 66d9a544f..d14e25b5c 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/page.tsx @@ -101,6 +101,7 @@ const getMembersWithMetadata = async () => { role: { notIn: [Role.employee, Role.auditor, Role.contractor], }, + deactivated: false, }, include: { user: true, diff --git a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/actions/deleteVendor.ts b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/actions/deleteVendor.ts index 51ef230c3..3ce5bb4c2 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/actions/deleteVendor.ts +++ b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/actions/deleteVendor.ts @@ -34,6 +34,7 @@ export const deleteVendor = authActionClient where: { organizationId: ctx.session.activeOrganizationId, userId: ctx.user.id, + deactivated: false, }, }); diff --git a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/data/queries.ts b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/data/queries.ts index a50090586..504a0c9e3 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/data/queries.ts +++ b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/data/queries.ts @@ -59,6 +59,7 @@ export const getAssignees = cache(async (orgId: string): Promise { role: { notIn: ['employee', 'contractor'], }, + deactivated: false, }, include: { user: true, diff --git a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/tasks/[taskId]/page.tsx b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/tasks/[taskId]/page.tsx index 8d4239106..a31c1185f 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/tasks/[taskId]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/tasks/[taskId]/page.tsx @@ -50,6 +50,7 @@ export default async function TaskPage({ params }: PageProps) { role: { notIn: ['employee', 'contractor'], }, + deactivated: false, }, include: { user: true, diff --git a/apps/app/src/app/(app)/[orgId]/vendors/backup-overview/layout.tsx b/apps/app/src/app/(app)/[orgId]/vendors/backup-overview/layout.tsx index e7317863d..15f8156ef 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/backup-overview/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/vendors/backup-overview/layout.tsx @@ -96,6 +96,7 @@ const getAssignees = cache(async () => { role: { notIn: ['employee', 'contractor'], }, + deactivated: false, }, include: { user: true, diff --git a/apps/app/src/app/(app)/onboarding/actions/complete-onboarding.ts b/apps/app/src/app/(app)/onboarding/actions/complete-onboarding.ts index bbe85dc1f..42dd4b20d 100644 --- a/apps/app/src/app/(app)/onboarding/actions/complete-onboarding.ts +++ b/apps/app/src/app/(app)/onboarding/actions/complete-onboarding.ts @@ -56,6 +56,7 @@ export const completeOnboarding = authActionClient where: { userId: ctx.user.id, organizationId: parsedInput.organizationId, + deactivated: false, }, }); diff --git a/apps/app/src/app/(app)/upgrade/[orgId]/page.tsx b/apps/app/src/app/(app)/upgrade/[orgId]/page.tsx index 2abf813fe..db8841713 100644 --- a/apps/app/src/app/(app)/upgrade/[orgId]/page.tsx +++ b/apps/app/src/app/(app)/upgrade/[orgId]/page.tsx @@ -28,6 +28,7 @@ export default async function UpgradePage({ params }: PageProps) { where: { organizationId: orgId, userId: authSession.user.id, + deactivated: false, }, include: { organization: true, diff --git a/apps/app/src/app/api/secrets/[id]/route.ts b/apps/app/src/app/api/secrets/[id]/route.ts index 9871f6620..6f98f3b0e 100644 --- a/apps/app/src/app/api/secrets/[id]/route.ts +++ b/apps/app/src/app/api/secrets/[id]/route.ts @@ -79,6 +79,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ where: { organizationId: validatedData.organizationId, userId: session.user.id, + deactivated: false, }, }); @@ -175,6 +176,7 @@ export async function DELETE( where: { organizationId, userId: session.user.id, + deactivated: false, }, }); diff --git a/apps/app/src/app/api/secrets/route.ts b/apps/app/src/app/api/secrets/route.ts index 8cbcce324..ea1215a54 100644 --- a/apps/app/src/app/api/secrets/route.ts +++ b/apps/app/src/app/api/secrets/route.ts @@ -80,6 +80,7 @@ export async function POST(request: NextRequest) { where: { organizationId: validatedData.organizationId, userId: session.user.id, + deactivated: false, }, }); diff --git a/apps/app/src/app/page.tsx b/apps/app/src/app/page.tsx index c83d58de7..57170a935 100644 --- a/apps/app/src/app/page.tsx +++ b/apps/app/src/app/page.tsx @@ -69,6 +69,7 @@ export default async function RootPage({ where: { organizationId: orgId, userId: session.user.id, + deactivated: false, }, }); diff --git a/apps/app/src/components/comments/CommentItem.tsx b/apps/app/src/components/comments/CommentItem.tsx index cdadb4746..8b15b718b 100644 --- a/apps/app/src/components/comments/CommentItem.tsx +++ b/apps/app/src/components/comments/CommentItem.tsx @@ -19,7 +19,21 @@ import { DropdownMenuTrigger, } from '@comp/ui/dropdown-menu'; import { Textarea } from '@comp/ui/textarea'; -import { FileIcon, FileText, ImageIcon, MoreHorizontal, Pencil, Trash2 } from 'lucide-react'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@comp/ui/tooltip'; +import { + AlertTriangle, + FileIcon, + FileText, + ImageIcon, + MoreHorizontal, + Pencil, + Trash2, +} from 'lucide-react'; import type React from 'react'; import { useState } from 'react'; import { toast } from 'sonner'; @@ -158,15 +172,31 @@ export function CommentItem({ comment, refreshComments }: CommentItemProps) { return ( <>
- - - - {comment.author.name?.charAt(0).toUpperCase() ?? '?'} - - +
+ + + + {comment.author.name?.charAt(0).toUpperCase() ?? '?'} + + + {comment.author.deactivated && ( + + + +
+ +
+
+ +

This user is deactivated.

+
+
+
+ )} +
diff --git a/apps/app/src/components/comments/Comments.tsx b/apps/app/src/components/comments/Comments.tsx index fb040115b..ba49f19e4 100644 --- a/apps/app/src/components/comments/Comments.tsx +++ b/apps/app/src/components/comments/Comments.tsx @@ -14,6 +14,7 @@ export type CommentWithAuthor = { name: string; email: string; image: string | null; + deactivated: boolean; }; attachments: Array<{ id: string; diff --git a/apps/app/src/hooks/use-comments-api.ts b/apps/app/src/hooks/use-comments-api.ts index 715828287..dbab2e0dd 100644 --- a/apps/app/src/hooks/use-comments-api.ts +++ b/apps/app/src/hooks/use-comments-api.ts @@ -20,6 +20,7 @@ export interface Comment { name: string; email: string; image: string | null; + deactivated: boolean; }; attachments: Array<{ id: string; @@ -175,6 +176,7 @@ export function useOptimisticComments(entityId: string, entityType: CommentEntit name: 'You', // Will be replaced with real author data email: '', image: null, + deactivated: false, }, attachments: [], // Will be populated by real response createdAt: new Date().toISOString(), diff --git a/apps/app/src/jobs/tasks/onboarding/backfill-training-videos-for-org.ts b/apps/app/src/jobs/tasks/onboarding/backfill-training-videos-for-org.ts index 21828c0ea..2b805517c 100644 --- a/apps/app/src/jobs/tasks/onboarding/backfill-training-videos-for-org.ts +++ b/apps/app/src/jobs/tasks/onboarding/backfill-training-videos-for-org.ts @@ -15,6 +15,7 @@ export const backfillTrainingVideosForOrg = task({ const members = await db.member.findMany({ where: { organizationId: payload.organizationId, + deactivated: false, }, select: { id: true, diff --git a/apps/app/src/jobs/tasks/onboarding/onboard-organization-helpers.ts b/apps/app/src/jobs/tasks/onboarding/onboard-organization-helpers.ts index d010b7f1c..0253120d5 100644 --- a/apps/app/src/jobs/tasks/onboarding/onboard-organization-helpers.ts +++ b/apps/app/src/jobs/tasks/onboarding/onboard-organization-helpers.ts @@ -273,6 +273,7 @@ export async function findCommentAuthor(organizationId: string) { where: { organizationId, OR: [{ role: { contains: 'owner' } }, { role: { contains: 'admin' } }], + deactivated: false, }, orderBy: [ { role: 'desc' }, // Prefer owner over admin diff --git a/apps/app/src/jobs/tasks/onboarding/onboard-organization.ts b/apps/app/src/jobs/tasks/onboarding/onboard-organization.ts index c7bc12638..f03ee3a71 100644 --- a/apps/app/src/jobs/tasks/onboarding/onboard-organization.ts +++ b/apps/app/src/jobs/tasks/onboarding/onboard-organization.ts @@ -79,6 +79,7 @@ export const onboardOrganization = task({ role: { contains: 'owner', }, + deactivated: false, }, }); diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/page.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/page.tsx index 79b16971c..06e715624 100644 --- a/apps/portal/src/app/(app)/(home)/[orgId]/page.tsx +++ b/apps/portal/src/app/(app)/(home)/[orgId]/page.tsx @@ -28,6 +28,7 @@ export default async function OrganizationPage({ params }: { params: Promise<{ o where: { userId: session.user.id, organizationId: orgId, + deactivated: false, }, include: { user: true, diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/policy/[policyId]/page.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/policy/[policyId]/page.tsx index 6bec73ba3..54ed5cee5 100644 --- a/apps/portal/src/app/(app)/(home)/[orgId]/policy/[policyId]/page.tsx +++ b/apps/portal/src/app/(app)/(home)/[orgId]/policy/[policyId]/page.tsx @@ -43,6 +43,7 @@ export default async function PolicyPage({ where: { userId: session.user.id, organizationId: orgId, + deactivated: false, }, }); diff --git a/apps/portal/src/app/(app)/(home)/actions/getPolicyPdfUrl.ts b/apps/portal/src/app/(app)/(home)/actions/getPolicyPdfUrl.ts index 092a45211..cd431dc53 100644 --- a/apps/portal/src/app/(app)/(home)/actions/getPolicyPdfUrl.ts +++ b/apps/portal/src/app/(app)/(home)/actions/getPolicyPdfUrl.ts @@ -35,7 +35,7 @@ export const getPolicyPdfUrl = authActionClient } const member = await db.member.findFirst({ - where: { userId: user.id, organizationId: policy.organizationId }, + where: { userId: user.id, organizationId: policy.organizationId, deactivated: false }, }); if (!member) { diff --git a/apps/portal/src/app/(app)/(home)/actions/markPolicyAsCompleted.ts b/apps/portal/src/app/(app)/(home)/actions/markPolicyAsCompleted.ts index 7439e294d..0c709cd6a 100644 --- a/apps/portal/src/app/(app)/(home)/actions/markPolicyAsCompleted.ts +++ b/apps/portal/src/app/(app)/(home)/actions/markPolicyAsCompleted.ts @@ -32,6 +32,7 @@ export const markPolicyAsCompleted = authActionClient const member = await db.member.findFirst({ where: { userId: user.id, + deactivated: false, }, }); diff --git a/apps/portal/src/app/(app)/(home)/actions/markVideoAsCompleted.ts b/apps/portal/src/app/(app)/(home)/actions/markVideoAsCompleted.ts index 87e23f5bd..fa28b61a6 100644 --- a/apps/portal/src/app/(app)/(home)/actions/markVideoAsCompleted.ts +++ b/apps/portal/src/app/(app)/(home)/actions/markVideoAsCompleted.ts @@ -39,6 +39,7 @@ export const markVideoAsCompleted = authActionClient where: { userId: user.id, organizationId: organizationId, + deactivated: false, }, }); diff --git a/apps/portal/src/app/api/download-agent/utils.ts b/apps/portal/src/app/api/download-agent/utils.ts index 8f8c783e3..2ed0493c2 100644 --- a/apps/portal/src/app/api/download-agent/utils.ts +++ b/apps/portal/src/app/api/download-agent/utils.ts @@ -54,6 +54,7 @@ export async function validateMemberAndOrg(userId: string, orgId: string) { where: { userId, organizationId: orgId, + deactivated: false, }, }); diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index a1393b099..400a6859b 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -9908,13 +9908,20 @@ "description": "User profile image URL", "example": "https://example.com/avatar.jpg", "nullable": true + }, + "deactivated": { + "type": "boolean", + "description": "Whether the user is deactivated", + "example": false, + "nullable": true } }, "required": [ "id", "name", "email", - "image" + "image", + "deactivated" ] }, "AttachmentMetadataDto": { diff --git a/packages/email/emails/unassigned-items-notification.tsx b/packages/email/emails/unassigned-items-notification.tsx new file mode 100644 index 000000000..a943b86e8 --- /dev/null +++ b/packages/email/emails/unassigned-items-notification.tsx @@ -0,0 +1,167 @@ +import { + Body, + Container, + Font, + Heading, + Html, + Link, + Section, + Preview, + Tailwind, + Text, +} from '@react-email/components'; +import { Footer } from '../components/footer'; +import { Logo } from '../components/logo'; + +interface UnassignedItem { + type: 'task' | 'policy' | 'risk' | 'vendor'; + id: string; + name: string; +} + +interface Props { + userName: string; + organizationName: string; + organizationId: string; + removedMemberName: string; + unassignedItems: UnassignedItem[]; +} + +export const UnassignedItemsNotificationEmail = ({ + userName, + organizationName, + organizationId, + removedMemberName, + unassignedItems, +}: Props) => { + const baseUrl = process.env.NEXT_PUBLIC_BETTER_AUTH_URL ?? 'https://app.trycomp.ai'; + const link = `${baseUrl}/${organizationId}`; + + + const getItemTypeLabel = (type: UnassignedItem['type']) => { + switch (type) { + case 'task': + return 'Task'; + case 'policy': + return 'Policy'; + case 'risk': + return 'Risk'; + case 'vendor': + return 'Vendor'; + } + }; + + const getItemUrl = (item: UnassignedItem) => { + switch (item.type) { + case 'task': + return `${baseUrl}/${organizationId}/tasks/${item.id}`; + case 'policy': + return `${baseUrl}/${organizationId}/policies/${item.id}`; + case 'risk': + return `${baseUrl}/${organizationId}/risk/${item.id}`; + case 'vendor': + return `${baseUrl}/${organizationId}/vendors/${item.id}`; + } + }; + + const groupedItems = unassignedItems.reduce( + (acc, item) => { + if (!acc[item.type]) { + acc[item.type] = []; + } + acc[item.type].push(item); + return acc; + }, + {} as Record, + ); + + return ( + + + + + + + + + Member removed - items require reassignment + + + + + + Member Removed - Items Require Reassignment + + + + Hi {userName}, + + + + {removedMemberName} has been removed from {organizationName}. + As a result, the following items that were previously assigned to them now require a new assignee: + + + {Object.entries(groupedItems).map(([type, items]) => ( +
+ + {getItemTypeLabel(type as UnassignedItem['type'])}s ({items.length}) + +
    + {items.map((item) => ( +
  • + + {item.name} + +
  • + ))} +
+
+ ))} + + + Please log in to assign these items to appropriate team members. + + +
+ + View Organization + +
+ +
+ +