diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index bf6d8b15fb..eff3454cf4 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -55,6 +55,7 @@ import { AdminFeatureFlagsModule } from './admin-feature-flags/admin-feature-fla import { TimelinesModule } from './timelines/timelines.module'; import { BackgroundChecksModule } from './background-checks/background-checks.module'; import { BillingModule } from './billing/billing.module'; +import { OffboardingChecklistModule } from './offboarding-checklist/offboarding-checklist.module'; @Module({ imports: [ @@ -122,6 +123,7 @@ import { BillingModule } from './billing/billing.module'; AdminOrganizationsModule, AdminFeatureFlagsModule, TimelinesModule, + OffboardingChecklistModule, ], controllers: [AppController], providers: [ diff --git a/apps/api/src/integration-platform/controllers/sync.controller.ts b/apps/api/src/integration-platform/controllers/sync.controller.ts index c893a27068..897620f1b4 100644 --- a/apps/api/src/integration-platform/controllers/sync.controller.ts +++ b/apps/api/src/integration-platform/controllers/sync.controller.ts @@ -48,6 +48,7 @@ interface GoogleWorkspaceUser { isAdmin?: boolean; suspended?: boolean; orgUnitPath?: string; + creationTime?: string; } interface GoogleWorkspaceUsersResponse { @@ -381,9 +382,12 @@ export class SyncController { }); if (existingMember) { - // Never reactivate deactivated members — whether deactivated manually - // by an admin or by a previous sync, they should stay deactivated. - // Admins can reactivate manually if needed. + if (!existingMember.onboardDate && gwUser.creationTime) { + await db.member.update({ + where: { id: existingMember.id }, + data: { onboardDate: new Date(gwUser.creationTime) }, + }); + } results.skipped++; results.details.push({ email: normalizedEmail, @@ -402,6 +406,7 @@ export class SyncController { userId, role: 'employee', isActive: true, + ...(gwUser.creationTime ? { onboardDate: new Date(gwUser.creationTime) } : {}), }, }); @@ -839,6 +844,12 @@ export class SyncController { }); if (existingMember) { + if (!existingMember.onboardDate && worker.start_date) { + await db.member.update({ + where: { id: existingMember.id }, + data: { onboardDate: new Date(worker.start_date) }, + }); + } if (existingMember.deactivated) { await db.member.update({ where: { id: existingMember.id }, @@ -865,6 +876,7 @@ export class SyncController { userId, role: 'employee', isActive: true, + ...(worker.start_date ? { onboardDate: new Date(worker.start_date) } : {}), }, }); results.imported++; @@ -1333,7 +1345,12 @@ export class SyncController { }); if (existingMember) { - // If member was deactivated but is now active in JumpCloud, reactivate them + if (!existingMember.onboardDate && jcUser.created) { + await db.member.update({ + where: { id: existingMember.id }, + data: { onboardDate: new Date(jcUser.created) }, + }); + } if (existingMember.deactivated) { await db.member.update({ where: { id: existingMember.id }, @@ -1365,6 +1382,7 @@ export class SyncController { userId, role: 'employee', isActive: true, + ...(jcUser.created ? { onboardDate: new Date(jcUser.created) } : {}), }, }); diff --git a/apps/api/src/integration-platform/services/generic-employee-sync.service.ts b/apps/api/src/integration-platform/services/generic-employee-sync.service.ts index 9ee3c0c22c..970656bb06 100644 --- a/apps/api/src/integration-platform/services/generic-employee-sync.service.ts +++ b/apps/api/src/integration-platform/services/generic-employee-sync.service.ts @@ -185,6 +185,9 @@ export class GenericEmployeeSyncService { ); } + const needsOnboardDate = + !existingMember.onboardDate && employee.startDate; + if (existingMember.deactivated && allowReactivation) { await db.member.update({ where: { id: existingMember.id }, @@ -192,6 +195,7 @@ export class GenericEmployeeSyncService { deactivated: false, isActive: true, ...(needsHeal ? { role: healedRole } : {}), + ...(needsOnboardDate ? { onboardDate: new Date(employee.startDate!) } : {}), }, }); results.reactivated++; @@ -200,10 +204,13 @@ export class GenericEmployeeSyncService { status: 'reactivated', }); } else { - if (needsHeal) { + if (needsHeal || needsOnboardDate) { await db.member.update({ where: { id: existingMember.id }, - data: { role: healedRole }, + data: { + ...(needsHeal ? { role: healedRole } : {}), + ...(needsOnboardDate ? { onboardDate: new Date(employee.startDate!) } : {}), + }, }); } results.skipped++; @@ -231,6 +238,7 @@ export class GenericEmployeeSyncService { userId: existingUser.id, role: sanitizedRole, isActive: true, + ...(employee.startDate ? { onboardDate: new Date(employee.startDate) } : {}), }, }); @@ -294,7 +302,11 @@ export class GenericEmployeeSyncService { try { await db.member.update({ where: { id: member.id }, - data: { deactivated: true, isActive: false }, + data: { + deactivated: true, + isActive: false, + offboardDate: member.offboardDate ?? new Date(), + }, }); results.deactivated++; results.details.push({ diff --git a/apps/api/src/offboarding-checklist/access-revocation.service.ts b/apps/api/src/offboarding-checklist/access-revocation.service.ts new file mode 100644 index 0000000000..2ed8c002ae --- /dev/null +++ b/apps/api/src/offboarding-checklist/access-revocation.service.ts @@ -0,0 +1,233 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { AttachmentEntityType, db } from '@db'; +import { AttachmentsService } from '../attachments/attachments.service'; + +@Injectable() +export class AccessRevocationService { + constructor(private readonly attachmentsService: AttachmentsService) {} + async getAccessRevocations(organizationId: string, memberId: string) { + const vendors = await db.vendor.findMany({ + where: { organizationId }, + select: { id: true, name: true, website: true, logoUrl: true }, + orderBy: { name: 'asc' }, + }); + + const revocations = await db.offboardingAccessRevocation.findMany({ + where: { organizationId, memberId }, + include: { + revokedBy: { select: { id: true, name: true, email: true } }, + }, + }); + + const revocationMap = new Map( + revocations.map((r) => [r.vendorId, r]), + ); + + const vendorList = await Promise.all( + vendors.map(async (vendor) => { + const revocation = revocationMap.get(vendor.id); + const domain = vendor.website?.replace(/^https?:\/\//, '').replace(/\/.*$/, '') ?? null; + const evidence = revocation + ? await this.attachmentsService.getAttachments( + organizationId, + revocation.id, + AttachmentEntityType.offboarding_checklist, + ) + : []; + return { + vendorId: vendor.id, + vendorName: vendor.name, + logoUrl: vendor.logoUrl ?? (domain ? `https://img.logo.dev/${domain}?token=pk_X-1ZO13GSgeOoUrIuJ6GMQ&size=64` : null), + revoked: !!revocation, + revokedAt: revocation?.revokedAt ?? null, + revokedBy: revocation?.revokedBy ?? null, + notes: revocation?.notes ?? null, + evidence, + }; + }), + ); + + return { + vendors: vendorList, + totalVendors: vendors.length, + revokedCount: revocations.length, + }; + } + + async revokeVendorAccess({ + organizationId, + memberId, + vendorId, + revokedById, + notes, + evidence, + }: { + organizationId: string; + memberId: string; + vendorId: string; + revokedById: string; + notes?: string; + evidence?: { fileName: string; fileType: string; fileData: string }; + }) { + const vendor = await db.vendor.findFirst({ + where: { id: vendorId, organizationId }, + }); + + if (!vendor) { + throw new NotFoundException('Vendor not found in this organization'); + } + + const existing = await db.offboardingAccessRevocation.findUnique({ + where: { memberId_vendorId: { memberId, vendorId } }, + }); + + if (existing) { + throw new BadRequestException( + 'Vendor access has already been revoked for this member', + ); + } + + const revocation = await db.offboardingAccessRevocation.create({ + data: { + organizationId, + memberId, + vendorId, + revokedById, + notes, + }, + include: { + revokedBy: { select: { id: true, name: true, email: true } }, + }, + }); + + if (evidence) { + await this.attachmentsService.uploadAttachment( + organizationId, + revocation.id, + AttachmentEntityType.offboarding_checklist, + evidence, + revokedById, + ); + } + + await this.syncAccessRevocationCompletion( + organizationId, + memberId, + revokedById, + ); + + return revocation; + } + + async undoVendorRevocation({ + organizationId, + memberId, + vendorId, + }: { + organizationId: string; + memberId: string; + vendorId: string; + }) { + const revocation = await db.offboardingAccessRevocation.findUnique({ + where: { memberId_vendorId: { memberId, vendorId } }, + }); + + if (!revocation) { + throw new NotFoundException('Revocation record not found'); + } + + await db.offboardingAccessRevocation.delete({ + where: { id: revocation.id }, + }); + + await this.syncAccessRevocationCompletion(organizationId, memberId); + + return { success: true }; + } + + async revokeAllVendorAccess({ + organizationId, + memberId, + revokedById, + }: { + organizationId: string; + memberId: string; + revokedById: string; + }) { + const vendors = await db.vendor.findMany({ + where: { organizationId }, + select: { id: true }, + }); + + const existing = await db.offboardingAccessRevocation.findMany({ + where: { organizationId, memberId }, + select: { vendorId: true }, + }); + + const existingSet = new Set(existing.map((r) => r.vendorId)); + const toCreate = vendors.filter((v) => !existingSet.has(v.id)); + + if (toCreate.length > 0) { + await db.offboardingAccessRevocation.createMany({ + data: toCreate.map((v) => ({ + organizationId, + memberId, + vendorId: v.id, + revokedById, + })), + skipDuplicates: true, + }); + } + + await this.syncAccessRevocationCompletion(organizationId, memberId, revokedById); + + return { confirmed: toCreate.length }; + } + + private async syncAccessRevocationCompletion( + organizationId: string, + memberId: string, + completedById?: string, + ) { + const templateItem = await db.offboardingChecklistTemplate.findFirst({ + where: { organizationId, isAccessRevocation: true, isEnabled: true }, + }); + + if (!templateItem) { + return; + } + + const { totalVendors, revokedCount } = await this.getAccessRevocations( + organizationId, + memberId, + ); + + const allRevoked = totalVendors > 0 && revokedCount === totalVendors; + + const existingCompletion = + await db.offboardingChecklistCompletion.findFirst({ + where: { organizationId, memberId, templateItemId: templateItem.id }, + }); + + if (allRevoked && !existingCompletion && completedById) { + await db.offboardingChecklistCompletion.create({ + data: { + organizationId, + memberId, + templateItemId: templateItem.id, + completedById, + }, + }); + } + + if (!allRevoked && existingCompletion) { + await db.offboardingChecklistCompletion.delete({ + where: { id: existingCompletion.id }, + }); + } + } +} diff --git a/apps/api/src/offboarding-checklist/default-checklist-items.ts b/apps/api/src/offboarding-checklist/default-checklist-items.ts new file mode 100644 index 0000000000..4676fb4678 --- /dev/null +++ b/apps/api/src/offboarding-checklist/default-checklist-items.ts @@ -0,0 +1,66 @@ +export const DEFAULT_OFFBOARDING_CHECKLIST_ITEMS = [ + { + title: 'Revoke system access', + description: + "Disable or remove the employee's access to all company systems, applications, and cloud services.", + evidenceRequired: true, + isAccessRevocation: true, + sortOrder: 1, + }, + { + title: 'Remove from identity provider', + description: + 'Remove the employee from your identity provider (e.g., Okta, Azure AD, Google Workspace).', + evidenceRequired: true, + isAccessRevocation: false, + sortOrder: 2, + }, + { + title: 'Retrieve company devices', + description: + 'Collect all company-owned hardware including laptops, phones, access badges, and security keys.', + evidenceRequired: true, + isAccessRevocation: false, + sortOrder: 3, + }, + { + title: 'Deactivate email and accounts', + description: + "Deactivate or redirect the employee's email account and remove from shared mailboxes and distribution lists.", + evidenceRequired: true, + isAccessRevocation: false, + sortOrder: 4, + }, + { + title: 'Revoke privileged access', + description: + 'Remove any elevated permissions, admin rights, SSH keys, API tokens, or shared credentials the employee had access to.', + evidenceRequired: true, + isAccessRevocation: false, + sortOrder: 5, + }, + { + title: 'Notify relevant teams', + description: + "Inform the employee's team, IT, HR, and any relevant stakeholders of the departure.", + evidenceRequired: false, + isAccessRevocation: false, + sortOrder: 6, + }, + { + title: 'Exit interview completed', + description: + 'Conduct an exit interview covering security reminders and NDA obligations.', + evidenceRequired: false, + isAccessRevocation: false, + sortOrder: 7, + }, + { + title: 'Update org chart and documentation', + description: + 'Remove the employee from the org chart, on-call rotations, and internal documentation.', + evidenceRequired: false, + isAccessRevocation: false, + sortOrder: 8, + }, +] as const; diff --git a/apps/api/src/offboarding-checklist/dto/complete-checklist-item.dto.ts b/apps/api/src/offboarding-checklist/dto/complete-checklist-item.dto.ts new file mode 100644 index 0000000000..017b7614a2 --- /dev/null +++ b/apps/api/src/offboarding-checklist/dto/complete-checklist-item.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsOptional, IsString, MaxLength, IsBase64 } from 'class-validator'; +import { IsMimeTypeField } from '../../utils/mime-type.validator'; + +export class CompleteChecklistItemDto { + @ApiProperty({ description: 'Optional notes', required: false }) + @IsOptional() + @IsString() + notes?: string; + + @ApiProperty({ description: 'Evidence file name', required: false }) + @IsOptional() + @IsString() + fileName?: string; + + @ApiProperty({ description: 'Evidence file MIME type', required: false }) + @IsOptional() + @IsMimeTypeField() + fileType?: string; + + @ApiProperty({ + description: 'Base64 encoded evidence file', + required: false, + }) + @IsOptional() + @IsString() + @MaxLength(134_217_728) + @IsBase64() + fileData?: string; +} diff --git a/apps/api/src/offboarding-checklist/dto/create-template-item.dto.ts b/apps/api/src/offboarding-checklist/dto/create-template-item.dto.ts new file mode 100644 index 0000000000..17ddd30d65 --- /dev/null +++ b/apps/api/src/offboarding-checklist/dto/create-template-item.dto.ts @@ -0,0 +1,25 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsNotEmpty, IsOptional, IsBoolean } from 'class-validator'; + +export class CreateTemplateItemDto { + @ApiProperty({ + description: 'Checklist item title', + example: 'Collect access badges', + }) + @IsString() + @IsNotEmpty() + title: string; + + @ApiProperty({ description: 'Guidance text for the admin', required: false }) + @IsOptional() + @IsString() + description?: string; + + @ApiProperty({ + description: 'Whether evidence upload is required', + required: false, + }) + @IsOptional() + @IsBoolean() + evidenceRequired?: boolean; +} diff --git a/apps/api/src/offboarding-checklist/dto/update-template-item.dto.ts b/apps/api/src/offboarding-checklist/dto/update-template-item.dto.ts new file mode 100644 index 0000000000..5d7594fda4 --- /dev/null +++ b/apps/api/src/offboarding-checklist/dto/update-template-item.dto.ts @@ -0,0 +1,29 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsOptional, IsBoolean, IsNumber } from 'class-validator'; + +export class UpdateTemplateItemDto { + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + title?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + description?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsBoolean() + evidenceRequired?: boolean; + + @ApiProperty({ required: false }) + @IsOptional() + @IsNumber() + sortOrder?: number; + + @ApiProperty({ required: false }) + @IsOptional() + @IsBoolean() + isEnabled?: boolean; +} diff --git a/apps/api/src/offboarding-checklist/offboarding-checklist.controller.ts b/apps/api/src/offboarding-checklist/offboarding-checklist.controller.ts new file mode 100644 index 0000000000..8b65174869 --- /dev/null +++ b/apps/api/src/offboarding-checklist/offboarding-checklist.controller.ts @@ -0,0 +1,283 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + Res, + UseGuards, +} from '@nestjs/common'; +import { ApiOperation, ApiParam, ApiSecurity, ApiTags } from '@nestjs/swagger'; +import { db } from '@db'; +import type { Response } from 'express'; +import { AuthContext, OrganizationId } from '../auth/auth-context.decorator'; +import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; +import { PermissionGuard } from '../auth/permission.guard'; +import { RequirePermission } from '../auth/require-permission.decorator'; +import type { AuthContext as AuthContextType } from '../auth/types'; +import { UploadAttachmentDto } from '../attachments/upload-attachment.dto'; +import { OffboardingChecklistService } from './offboarding-checklist.service'; +import { OffboardingExportService } from './offboarding-export.service'; +import { CreateTemplateItemDto } from './dto/create-template-item.dto'; +import { UpdateTemplateItemDto } from './dto/update-template-item.dto'; +import { CompleteChecklistItemDto } from './dto/complete-checklist-item.dto'; + +@ApiTags('Offboarding Checklist') +@Controller({ path: 'offboarding-checklist', version: '1' }) +@UseGuards(HybridAuthGuard, PermissionGuard) +@ApiSecurity('apikey') +export class OffboardingChecklistController { + constructor( + private readonly offboardingChecklistService: OffboardingChecklistService, + private readonly offboardingExportService: OffboardingExportService, + ) {} + + @Get('pending') + @RequirePermission('member', 'read') + @ApiOperation({ summary: 'Get members with pending offboarding checklists' }) + async getPendingOffboardings( + @OrganizationId() organizationId: string, + ) { + return this.offboardingChecklistService.getPendingOffboardings( + organizationId, + ); + } + + @Get('template') + @RequirePermission('member', 'read') + async getTemplate(@OrganizationId() organizationId: string) { + return this.offboardingChecklistService.getTemplate(organizationId); + } + + @Post('template') + @RequirePermission('member', 'update') + async createTemplateItem( + @OrganizationId() organizationId: string, + @Body() dto: CreateTemplateItemDto, + ) { + return this.offboardingChecklistService.createTemplateItem( + organizationId, + dto, + ); + } + + @Patch('template/:id') + @RequirePermission('member', 'update') + async updateTemplateItem( + @OrganizationId() organizationId: string, + @Param('id') id: string, + @Body() dto: UpdateTemplateItemDto, + ) { + return this.offboardingChecklistService.updateTemplateItem( + organizationId, + id, + dto, + ); + } + + @Delete('template/:id') + @RequirePermission('member', 'update') + async deleteTemplateItem( + @OrganizationId() organizationId: string, + @Param('id') id: string, + ) { + return this.offboardingChecklistService.deleteTemplateItem( + organizationId, + id, + ); + } + + @Get('member/:memberId') + @RequirePermission('member', 'read') + async getMemberChecklist( + @OrganizationId() organizationId: string, + @Param('memberId') memberId: string, + ) { + return this.offboardingChecklistService.getMemberChecklist( + organizationId, + memberId, + ); + } + + @Get('export-all') + @RequirePermission('member', 'read') + @ApiOperation({ summary: 'Export all offboarding evidence as a zip file' }) + async exportAllEvidence( + @OrganizationId() organizationId: string, + @Res() res: Response, + ) { + const org = await db.organization.findFirst({ + where: { id: organizationId }, + select: { name: true }, + }); + const safeOrgName = (org?.name ?? 'org').replace(/[^a-zA-Z0-9]/g, '-').toLowerCase(); + const date = new Date().toISOString().split('T')[0]; + res.set({ + 'Content-Type': 'application/zip', + 'Content-Disposition': `attachment; filename="${safeOrgName}-offboardings-${date}.zip"`, + }); + await this.offboardingExportService.exportAllOffboardings({ + organizationId, + output: res, + }); + } + + @Get('member/:memberId/export') + @RequirePermission('member', 'read') + @ApiOperation({ summary: 'Export offboarding evidence as a zip file' }) + @ApiParam({ name: 'memberId', description: 'Member ID' }) + async exportEvidence( + @Param('memberId') memberId: string, + @OrganizationId() organizationId: string, + @Res() res: Response, + ) { + const member = await db.member.findFirst({ + where: { id: memberId, organizationId }, + include: { user: { select: { name: true } } }, + }); + + const safeName = (member?.user.name ?? 'member').replace( + /[^a-zA-Z0-9]/g, + '-', + ); + const date = new Date().toISOString().split('T')[0]; + + res.set({ + 'Content-Type': 'application/zip', + 'Content-Disposition': `attachment; filename="offboarding-${safeName}-${date}.zip"`, + }); + + await this.offboardingExportService.exportMemberEvidence({ + organizationId, + memberId, + output: res, + }); + } + + @Post('member/:memberId/item/:templateItemId/complete') + @RequirePermission('member', 'update') + async completeItem( + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + @Param('memberId') memberId: string, + @Param('templateItemId') templateItemId: string, + @Body() dto: CompleteChecklistItemDto, + ) { + return this.offboardingChecklistService.completeItem({ + organizationId, + memberId, + templateItemId, + completedById: authContext.userId!, + dto, + }); + } + + @Delete('member/:memberId/item/:templateItemId/complete') + @RequirePermission('member', 'update') + async uncompleteItem( + @OrganizationId() organizationId: string, + @Param('memberId') memberId: string, + @Param('templateItemId') templateItemId: string, + ) { + return this.offboardingChecklistService.uncompleteItem({ + organizationId, + memberId, + templateItemId, + }); + } + + @Post('member/:memberId/item/:templateItemId/evidence') + @RequirePermission('member', 'update') + async uploadEvidence( + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + @Param('memberId') memberId: string, + @Param('templateItemId') templateItemId: string, + @Body() uploadDto: UploadAttachmentDto, + ) { + return this.offboardingChecklistService.uploadEvidenceToCompletion({ + organizationId, + memberId, + templateItemId, + uploadDto, + userId: authContext.userId!, + }); + } + + @Get('member/:memberId/access-revocations') + @RequirePermission('member', 'read') + @ApiOperation({ + summary: 'Get vendor access revocation status for a member', + }) + @ApiParam({ name: 'memberId', description: 'Member ID' }) + async getAccessRevocations( + @OrganizationId() organizationId: string, + @Param('memberId') memberId: string, + ) { + return this.offboardingChecklistService.getAccessRevocations( + organizationId, + memberId, + ); + } + + @Post('member/:memberId/access-revocations/confirm-all') + @RequirePermission('member', 'update') + @ApiOperation({ summary: 'Confirm all vendor access as revoked' }) + @ApiParam({ name: 'memberId', description: 'Member ID' }) + async revokeAllVendorAccess( + @OrganizationId() organizationId: string, + @Param('memberId') memberId: string, + @AuthContext() authContext: AuthContextType, + ) { + return this.offboardingChecklistService.revokeAllVendorAccess({ + organizationId, + memberId, + revokedById: authContext.userId!, + }); + } + + @Post('member/:memberId/access-revocations/:vendorId') + @RequirePermission('member', 'update') + @ApiOperation({ summary: 'Mark vendor access as revoked' }) + @ApiParam({ name: 'memberId', description: 'Member ID' }) + @ApiParam({ name: 'vendorId', description: 'Vendor ID' }) + async revokeVendorAccess( + @OrganizationId() organizationId: string, + @Param('memberId') memberId: string, + @Param('vendorId') vendorId: string, + @AuthContext() authContext: AuthContextType, + @Body() body: { notes?: string; fileName?: string; fileType?: string; fileData?: string }, + ) { + const evidence = body?.fileName && body?.fileType && body?.fileData + ? { fileName: body.fileName, fileType: body.fileType, fileData: body.fileData } + : undefined; + return this.offboardingChecklistService.revokeVendorAccess({ + organizationId, + memberId, + vendorId, + revokedById: authContext.userId!, + notes: body?.notes, + evidence, + }); + } + + @Delete('member/:memberId/access-revocations/:vendorId') + @RequirePermission('member', 'update') + @ApiOperation({ summary: 'Undo vendor access revocation' }) + @ApiParam({ name: 'memberId', description: 'Member ID' }) + @ApiParam({ name: 'vendorId', description: 'Vendor ID' }) + async undoVendorRevocation( + @OrganizationId() organizationId: string, + @Param('memberId') memberId: string, + @Param('vendorId') vendorId: string, + ) { + return this.offboardingChecklistService.undoVendorRevocation({ + organizationId, + memberId, + vendorId, + }); + } + +} diff --git a/apps/api/src/offboarding-checklist/offboarding-checklist.module.ts b/apps/api/src/offboarding-checklist/offboarding-checklist.module.ts new file mode 100644 index 0000000000..0d416ebbaf --- /dev/null +++ b/apps/api/src/offboarding-checklist/offboarding-checklist.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { AttachmentsModule } from '../attachments/attachments.module'; +import { AccessRevocationService } from './access-revocation.service'; +import { OffboardingChecklistController } from './offboarding-checklist.controller'; +import { OffboardingChecklistService } from './offboarding-checklist.service'; +import { OffboardingExportService } from './offboarding-export.service'; + +@Module({ + imports: [AuthModule, AttachmentsModule], + controllers: [OffboardingChecklistController], + providers: [ + OffboardingChecklistService, + AccessRevocationService, + OffboardingExportService, + ], + exports: [OffboardingChecklistService], +}) +export class OffboardingChecklistModule {} diff --git a/apps/api/src/offboarding-checklist/offboarding-checklist.service.spec.ts b/apps/api/src/offboarding-checklist/offboarding-checklist.service.spec.ts new file mode 100644 index 0000000000..b277a5f585 --- /dev/null +++ b/apps/api/src/offboarding-checklist/offboarding-checklist.service.spec.ts @@ -0,0 +1,543 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; + +const mockDb = { + offboardingChecklistTemplate: { + findMany: jest.fn(), + findFirst: jest.fn(), + count: jest.fn(), + create: jest.fn(), + createMany: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + aggregate: jest.fn(), + }, + offboardingChecklistCompletion: { + findMany: jest.fn(), + findFirst: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + }, + offboardingAccessRevocation: { + findMany: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + }, + vendor: { + findMany: jest.fn(), + findFirst: jest.fn(), + }, +}; + +jest.mock('@db', () => ({ + db: mockDb, + AttachmentEntityType: { + offboarding_checklist: 'offboarding_checklist', + }, +})); + +import { OffboardingChecklistService } from './offboarding-checklist.service'; +import { AccessRevocationService } from './access-revocation.service'; +import { DEFAULT_OFFBOARDING_CHECKLIST_ITEMS } from './default-checklist-items'; + +describe('OffboardingChecklistService', () => { + const mockAttachmentsService = { + getAttachments: jest.fn(), + uploadAttachment: jest.fn(), + deleteAttachment: jest.fn(), + }; + + let service: OffboardingChecklistService; + let accessRevocationService: AccessRevocationService; + + beforeEach(() => { + jest.clearAllMocks(); + accessRevocationService = new AccessRevocationService(); + service = new OffboardingChecklistService( + mockAttachmentsService as never, + accessRevocationService, + ); + }); + + describe('getTemplate', () => { + it('seeds defaults when none exist', async () => { + mockDb.offboardingChecklistTemplate.count.mockResolvedValue(0); + mockDb.offboardingChecklistTemplate.createMany.mockResolvedValue({ + count: DEFAULT_OFFBOARDING_CHECKLIST_ITEMS.length, + }); + mockDb.offboardingChecklistTemplate.findMany.mockResolvedValue( + DEFAULT_OFFBOARDING_CHECKLIST_ITEMS.map((item, i) => ({ + id: `oct_${i}`, + organizationId: 'org_1', + ...item, + isDefault: true, + isEnabled: true, + })), + ); + + const result = await service.getTemplate('org_1'); + + expect( + mockDb.offboardingChecklistTemplate.createMany, + ).toHaveBeenCalledWith({ + data: expect.arrayContaining([ + expect.objectContaining({ + organizationId: 'org_1', + title: 'Revoke system access', + isDefault: true, + isEnabled: true, + }), + ]), + }); + expect(result).toHaveLength(DEFAULT_OFFBOARDING_CHECKLIST_ITEMS.length); + }); + + it('returns existing items without seeding', async () => { + const existingItems = [ + { + id: 'oct_1', + organizationId: 'org_1', + title: 'Custom item', + isDefault: false, + isEnabled: true, + sortOrder: 1, + }, + ]; + mockDb.offboardingChecklistTemplate.count.mockResolvedValue(1); + mockDb.offboardingChecklistTemplate.findMany.mockResolvedValue( + existingItems, + ); + + const result = await service.getTemplate('org_1'); + + expect( + mockDb.offboardingChecklistTemplate.createMany, + ).not.toHaveBeenCalled(); + expect(result).toEqual(existingItems); + }); + }); + + describe('getMemberChecklist', () => { + it('returns items with completion status', async () => { + mockDb.offboardingChecklistTemplate.count.mockResolvedValue(2); + mockDb.offboardingChecklistTemplate.findMany.mockResolvedValue([ + { + id: 'oct_1', + organizationId: 'org_1', + title: 'Item 1', + isEnabled: true, + sortOrder: 1, + }, + { + id: 'oct_2', + organizationId: 'org_1', + title: 'Item 2', + isEnabled: true, + sortOrder: 2, + }, + ]); + mockDb.offboardingChecklistCompletion.findMany.mockResolvedValue([ + { + id: 'occ_1', + templateItemId: 'oct_1', + memberId: 'mem_1', + completedById: 'usr_1', + completedBy: { id: 'usr_1', name: 'Test User' }, + }, + ]); + mockAttachmentsService.getAttachments.mockResolvedValue([ + { id: 'att_1', name: 'evidence.pdf' }, + ]); + + const result = await service.getMemberChecklist('org_1', 'mem_1'); + + expect(result.totalItems).toBe(2); + expect(result.completedItems).toBe(1); + expect(result.items[0].completed).toBe(true); + expect(result.items[0].evidence).toHaveLength(1); + expect(result.items[1].completed).toBe(false); + expect(result.items[1].evidence).toHaveLength(0); + }); + }); + + describe('completeItem', () => { + it('creates completion record', async () => { + mockDb.offboardingChecklistCompletion.findFirst.mockResolvedValue(null); + mockDb.offboardingChecklistTemplate.findFirst.mockResolvedValue({ + id: 'oct_1', + organizationId: 'org_1', + isEnabled: true, + }); + mockDb.offboardingChecklistCompletion.create.mockResolvedValue({ + id: 'occ_1', + organizationId: 'org_1', + memberId: 'mem_1', + templateItemId: 'oct_1', + completedById: 'usr_1', + notes: 'Done', + }); + + const result = await service.completeItem({ + organizationId: 'org_1', + memberId: 'mem_1', + templateItemId: 'oct_1', + completedById: 'usr_1', + dto: { notes: 'Done' }, + }); + + expect(result.id).toBe('occ_1'); + expect( + mockDb.offboardingChecklistCompletion.create, + ).toHaveBeenCalledWith({ + data: { + organizationId: 'org_1', + memberId: 'mem_1', + templateItemId: 'oct_1', + completedById: 'usr_1', + notes: 'Done', + }, + }); + }); + + it('throws if already completed', async () => { + mockDb.offboardingChecklistCompletion.findFirst.mockResolvedValue({ + id: 'occ_1', + }); + + await expect( + service.completeItem({ + organizationId: 'org_1', + memberId: 'mem_1', + templateItemId: 'oct_1', + completedById: 'usr_1', + dto: {}, + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('throws if template item not found', async () => { + mockDb.offboardingChecklistCompletion.findFirst.mockResolvedValue(null); + mockDb.offboardingChecklistTemplate.findFirst.mockResolvedValue(null); + + await expect( + service.completeItem({ + organizationId: 'org_1', + memberId: 'mem_1', + templateItemId: 'oct_invalid', + completedById: 'usr_1', + dto: {}, + }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('uploads evidence when file data is provided', async () => { + mockDb.offboardingChecklistCompletion.findFirst.mockResolvedValue(null); + mockDb.offboardingChecklistTemplate.findFirst.mockResolvedValue({ + id: 'oct_1', + organizationId: 'org_1', + isEnabled: true, + }); + mockDb.offboardingChecklistCompletion.create.mockResolvedValue({ + id: 'occ_1', + organizationId: 'org_1', + memberId: 'mem_1', + templateItemId: 'oct_1', + completedById: 'usr_1', + }); + mockAttachmentsService.uploadAttachment.mockResolvedValue({ + id: 'att_1', + }); + + await service.completeItem({ + organizationId: 'org_1', + memberId: 'mem_1', + templateItemId: 'oct_1', + completedById: 'usr_1', + dto: { + fileName: 'evidence.pdf', + fileType: 'application/pdf', + fileData: 'base64data', + }, + }); + + expect(mockAttachmentsService.uploadAttachment).toHaveBeenCalledWith( + 'org_1', + 'occ_1', + 'offboarding_checklist', + { + fileName: 'evidence.pdf', + fileData: 'base64data', + fileType: 'application/pdf', + }, + 'usr_1', + ); + }); + }); + + describe('uncompleteItem', () => { + it('deletes completion and associated evidence', async () => { + mockDb.offboardingChecklistCompletion.findFirst.mockResolvedValue({ + id: 'occ_1', + }); + mockAttachmentsService.getAttachments.mockResolvedValue([ + { id: 'att_1' }, + { id: 'att_2' }, + ]); + mockDb.offboardingChecklistCompletion.delete.mockResolvedValue({}); + + await service.uncompleteItem({ + organizationId: 'org_1', + memberId: 'mem_1', + templateItemId: 'oct_1', + }); + + expect(mockAttachmentsService.deleteAttachment).toHaveBeenCalledTimes(2); + expect( + mockDb.offboardingChecklistCompletion.delete, + ).toHaveBeenCalledWith({ where: { id: 'occ_1' } }); + }); + + it('throws if completion not found', async () => { + mockDb.offboardingChecklistCompletion.findFirst.mockResolvedValue(null); + + await expect( + service.uncompleteItem({ + organizationId: 'org_1', + memberId: 'mem_1', + templateItemId: 'oct_1', + }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + }); + + describe('deleteTemplateItem', () => { + it('soft-disables default items', async () => { + mockDb.offboardingChecklistTemplate.findFirst.mockResolvedValue({ + id: 'oct_1', + organizationId: 'org_1', + isDefault: true, + }); + mockDb.offboardingChecklistTemplate.update.mockResolvedValue({ + id: 'oct_1', + isEnabled: false, + }); + + const result = await service.deleteTemplateItem('org_1', 'oct_1'); + + expect( + mockDb.offboardingChecklistTemplate.update, + ).toHaveBeenCalledWith({ + where: { id: 'oct_1' }, + data: { isEnabled: false }, + }); + expect( + mockDb.offboardingChecklistTemplate.delete, + ).not.toHaveBeenCalled(); + expect(result.isEnabled).toBe(false); + }); + + it('hard-deletes custom items', async () => { + mockDb.offboardingChecklistTemplate.findFirst.mockResolvedValue({ + id: 'oct_2', + organizationId: 'org_1', + isDefault: false, + }); + mockDb.offboardingChecklistTemplate.delete.mockResolvedValue({ + id: 'oct_2', + }); + + await service.deleteTemplateItem('org_1', 'oct_2'); + + expect( + mockDb.offboardingChecklistTemplate.delete, + ).toHaveBeenCalledWith({ where: { id: 'oct_2' } }); + expect( + mockDb.offboardingChecklistTemplate.update, + ).not.toHaveBeenCalled(); + }); + + it('throws if item not found', async () => { + mockDb.offboardingChecklistTemplate.findFirst.mockResolvedValue(null); + + await expect( + service.deleteTemplateItem('org_1', 'oct_invalid'), + ).rejects.toBeInstanceOf(NotFoundException); + }); + }); + + describe('uploadEvidenceToCompletion', () => { + it('uploads evidence to a completed item', async () => { + mockDb.offboardingChecklistCompletion.findFirst.mockResolvedValue({ + id: 'occ_1', + }); + mockAttachmentsService.uploadAttachment.mockResolvedValue({ + id: 'att_1', + }); + + const result = await service.uploadEvidenceToCompletion({ + organizationId: 'org_1', + memberId: 'mem_1', + templateItemId: 'oct_1', + uploadDto: { + fileName: 'screenshot.png', + fileType: 'image/png', + fileData: 'base64data', + }, + userId: 'usr_1', + }); + + expect(mockAttachmentsService.uploadAttachment).toHaveBeenCalledWith( + 'org_1', + 'occ_1', + 'offboarding_checklist', + { + fileName: 'screenshot.png', + fileType: 'image/png', + fileData: 'base64data', + }, + 'usr_1', + ); + expect(result.id).toBe('att_1'); + }); + + it('throws if item not yet completed', async () => { + mockDb.offboardingChecklistCompletion.findFirst.mockResolvedValue(null); + + await expect( + service.uploadEvidenceToCompletion({ + organizationId: 'org_1', + memberId: 'mem_1', + templateItemId: 'oct_1', + uploadDto: { + fileName: 'screenshot.png', + fileType: 'image/png', + fileData: 'base64data', + }, + userId: 'usr_1', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + }); + + describe('getAccessRevocations', () => { + it('returns vendor list with revocation status', async () => { + mockDb.vendor.findMany.mockResolvedValue([ + { id: 'vnd_1', name: 'Slack' }, + { id: 'vnd_2', name: 'AWS' }, + ]); + mockDb.offboardingAccessRevocation.findMany.mockResolvedValue([ + { + vendorId: 'vnd_1', + revokedBy: { id: 'usr_1', name: 'Jane', email: 'jane@test.com' }, + revokedAt: new Date(), + notes: null, + }, + ]); + + const result = await service.getAccessRevocations('org_1', 'mem_1'); + + expect(result.totalVendors).toBe(2); + expect(result.revokedCount).toBe(1); + expect(result.vendors[0].revoked).toBe(true); + expect(result.vendors[1].revoked).toBe(false); + }); + + it('returns empty when no vendors exist', async () => { + mockDb.vendor.findMany.mockResolvedValue([]); + mockDb.offboardingAccessRevocation.findMany.mockResolvedValue([]); + + const result = await service.getAccessRevocations('org_1', 'mem_1'); + + expect(result.totalVendors).toBe(0); + expect(result.revokedCount).toBe(0); + expect(result.vendors).toHaveLength(0); + }); + }); + + describe('revokeVendorAccess', () => { + it('creates revocation record', async () => { + mockDb.vendor.findFirst.mockResolvedValue({ id: 'vnd_1' }); + mockDb.offboardingAccessRevocation.findUnique.mockResolvedValue(null); + mockDb.offboardingAccessRevocation.create.mockResolvedValue({ + id: 'oar_1', + vendorId: 'vnd_1', + revokedBy: { id: 'usr_1', name: 'Jane', email: 'jane@test.com' }, + }); + // syncAccessRevocationCompletion mocks + mockDb.offboardingChecklistTemplate.findFirst.mockResolvedValue(null); + + const result = await service.revokeVendorAccess({ + organizationId: 'org_1', + memberId: 'mem_1', + vendorId: 'vnd_1', + revokedById: 'usr_1', + }); + + expect(mockDb.offboardingAccessRevocation.create).toHaveBeenCalled(); + expect(result.id).toBe('oar_1'); + }); + + it('throws if vendor not found', async () => { + mockDb.vendor.findFirst.mockResolvedValue(null); + + await expect( + service.revokeVendorAccess({ + organizationId: 'org_1', + memberId: 'mem_1', + vendorId: 'vnd_invalid', + revokedById: 'usr_1', + }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('throws if already revoked', async () => { + mockDb.vendor.findFirst.mockResolvedValue({ id: 'vnd_1' }); + mockDb.offboardingAccessRevocation.findUnique.mockResolvedValue({ + id: 'oar_1', + }); + + await expect( + service.revokeVendorAccess({ + organizationId: 'org_1', + memberId: 'mem_1', + vendorId: 'vnd_1', + revokedById: 'usr_1', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + }); + + describe('undoVendorRevocation', () => { + it('deletes revocation record', async () => { + mockDb.offboardingAccessRevocation.findUnique.mockResolvedValue({ + id: 'oar_1', + }); + mockDb.offboardingAccessRevocation.delete.mockResolvedValue({}); + // syncAccessRevocationCompletion mocks + mockDb.offboardingChecklistTemplate.findFirst.mockResolvedValue(null); + + const result = await service.undoVendorRevocation({ + organizationId: 'org_1', + memberId: 'mem_1', + vendorId: 'vnd_1', + }); + + expect( + mockDb.offboardingAccessRevocation.delete, + ).toHaveBeenCalledWith({ where: { id: 'oar_1' } }); + expect(result.success).toBe(true); + }); + + it('throws if revocation not found', async () => { + mockDb.offboardingAccessRevocation.findUnique.mockResolvedValue(null); + + await expect( + service.undoVendorRevocation({ + organizationId: 'org_1', + memberId: 'mem_1', + vendorId: 'vnd_1', + }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + }); +}); diff --git a/apps/api/src/offboarding-checklist/offboarding-checklist.service.ts b/apps/api/src/offboarding-checklist/offboarding-checklist.service.ts new file mode 100644 index 0000000000..185857efdd --- /dev/null +++ b/apps/api/src/offboarding-checklist/offboarding-checklist.service.ts @@ -0,0 +1,372 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { AttachmentEntityType, db } from '@db'; +import { AttachmentsService } from '../attachments/attachments.service'; +import { AccessRevocationService } from './access-revocation.service'; +import { DEFAULT_OFFBOARDING_CHECKLIST_ITEMS } from './default-checklist-items'; + +interface CompleteChecklistItemDto { + notes?: string; + fileName?: string; + fileType?: string; + fileData?: string; +} + +interface UploadEvidenceDto { + fileName: string; + fileType: string; + fileData: string; + description?: string; +} + +@Injectable() +export class OffboardingChecklistService { + constructor( + private readonly attachmentsService: AttachmentsService, + private readonly accessRevocationService: AccessRevocationService, + ) {} + + async getTemplate(organizationId: string) { + await this.seedDefaultsIfNeeded(organizationId); + + return db.offboardingChecklistTemplate.findMany({ + where: { organizationId }, + orderBy: { sortOrder: 'asc' }, + }); + } + + async createTemplateItem( + organizationId: string, + dto: { + title: string; + description?: string; + evidenceRequired?: boolean; + }, + ) { + const maxSortOrder = await db.offboardingChecklistTemplate.aggregate({ + where: { organizationId }, + _max: { sortOrder: true }, + }); + + return db.offboardingChecklistTemplate.create({ + data: { + organizationId, + title: dto.title, + description: dto.description, + evidenceRequired: dto.evidenceRequired ?? false, + sortOrder: (maxSortOrder._max.sortOrder ?? 0) + 1, + isDefault: false, + isEnabled: true, + }, + }); + } + + async updateTemplateItem( + organizationId: string, + templateItemId: string, + dto: { + title?: string; + description?: string; + evidenceRequired?: boolean; + sortOrder?: number; + isEnabled?: boolean; + }, + ) { + const item = await db.offboardingChecklistTemplate.findFirst({ + where: { id: templateItemId, organizationId }, + }); + + if (!item) { + throw new NotFoundException('Template item not found'); + } + + return db.offboardingChecklistTemplate.update({ + where: { id: templateItemId }, + data: dto, + }); + } + + async deleteTemplateItem(organizationId: string, templateItemId: string) { + const item = await db.offboardingChecklistTemplate.findFirst({ + where: { id: templateItemId, organizationId }, + }); + + if (!item) { + throw new NotFoundException('Template item not found'); + } + + if (item.isDefault) { + return db.offboardingChecklistTemplate.update({ + where: { id: templateItemId }, + data: { isEnabled: false }, + }); + } + + return db.offboardingChecklistTemplate.delete({ + where: { id: templateItemId }, + }); + } + + async getMemberChecklist(organizationId: string, memberId: string) { + await this.seedDefaultsIfNeeded(organizationId); + + const templateItems = await db.offboardingChecklistTemplate.findMany({ + where: { organizationId, isEnabled: true }, + orderBy: { sortOrder: 'asc' }, + }); + + const completions = await db.offboardingChecklistCompletion.findMany({ + where: { organizationId, memberId }, + include: { completedBy: { select: { id: true, name: true } } }, + }); + + const completionMap = new Map( + completions.map((c) => [c.templateItemId, c]), + ); + + const items = await Promise.all( + templateItems.map(async (template) => { + const completion = completionMap.get(template.id); + const evidence = completion + ? await this.attachmentsService.getAttachments( + organizationId, + completion.id, + AttachmentEntityType.offboarding_checklist, + ) + : []; + + return { + ...template, + templateItemId: template.id, + completed: !!completion, + completion: completion ?? null, + evidence, + }; + }), + ); + + return { + items, + totalItems: items.length, + completedItems: items.filter((i) => i.completed).length, + }; + } + + async completeItem({ + organizationId, + memberId, + templateItemId, + completedById, + dto, + }: { + organizationId: string; + memberId: string; + templateItemId: string; + completedById: string; + dto: CompleteChecklistItemDto; + }) { + const existing = await db.offboardingChecklistCompletion.findFirst({ + where: { organizationId, memberId, templateItemId }, + }); + + if (existing) { + throw new BadRequestException('Item is already completed'); + } + + const template = await db.offboardingChecklistTemplate.findFirst({ + where: { id: templateItemId, organizationId, isEnabled: true }, + }); + + if (!template) { + throw new NotFoundException('Template item not found'); + } + + const completion = await db.offboardingChecklistCompletion.create({ + data: { + organizationId, + memberId, + templateItemId, + completedById, + notes: dto.notes, + }, + }); + + if (dto.fileName && dto.fileData && dto.fileType) { + await this.attachmentsService.uploadAttachment( + organizationId, + completion.id, + AttachmentEntityType.offboarding_checklist, + { + fileName: dto.fileName, + fileData: dto.fileData, + fileType: dto.fileType, + }, + completedById, + ); + } + + return completion; + } + + async uncompleteItem({ + organizationId, + memberId, + templateItemId, + }: { + organizationId: string; + memberId: string; + templateItemId: string; + }) { + const completion = await db.offboardingChecklistCompletion.findFirst({ + where: { organizationId, memberId, templateItemId }, + }); + + if (!completion) { + throw new NotFoundException('Completion not found'); + } + + const attachments = await this.attachmentsService.getAttachments( + organizationId, + completion.id, + AttachmentEntityType.offboarding_checklist, + ); + + for (const attachment of attachments) { + await this.attachmentsService.deleteAttachment( + organizationId, + attachment.id, + ); + } + + await db.offboardingChecklistCompletion.delete({ + where: { id: completion.id }, + }); + } + + async uploadEvidenceToCompletion({ + organizationId, + memberId, + templateItemId, + uploadDto, + userId, + }: { + organizationId: string; + memberId: string; + templateItemId: string; + uploadDto: UploadEvidenceDto; + userId: string; + }) { + const completion = await db.offboardingChecklistCompletion.findFirst({ + where: { organizationId, memberId, templateItemId }, + }); + + if (!completion) { + throw new BadRequestException( + 'Item must be completed before uploading evidence', + ); + } + + return this.attachmentsService.uploadAttachment( + organizationId, + completion.id, + AttachmentEntityType.offboarding_checklist, + uploadDto, + userId, + ); + } + + async getAccessRevocations(organizationId: string, memberId: string) { + return this.accessRevocationService.getAccessRevocations( + organizationId, + memberId, + ); + } + + async revokeVendorAccess(params: { + organizationId: string; + memberId: string; + vendorId: string; + revokedById: string; + notes?: string; + evidence?: { fileName: string; fileType: string; fileData: string }; + }) { + return this.accessRevocationService.revokeVendorAccess(params); + } + + async undoVendorRevocation(params: { + organizationId: string; + memberId: string; + vendorId: string; + }) { + return this.accessRevocationService.undoVendorRevocation(params); + } + + async revokeAllVendorAccess(params: { + organizationId: string; + memberId: string; + revokedById: string; + }) { + return this.accessRevocationService.revokeAllVendorAccess(params); + } + + async getPendingOffboardings(organizationId: string) { + await this.seedDefaultsIfNeeded(organizationId); + + const totalEnabled = await db.offboardingChecklistTemplate.count({ + where: { organizationId, isEnabled: true }, + }); + + const offboardedMembers = await db.member.findMany({ + where: { + organizationId, + offboardDate: { not: null }, + deactivated: true, + }, + include: { + user: { select: { id: true, name: true, email: true, image: true } }, + offboardingChecklistCompletions: { select: { id: true } }, + }, + orderBy: { offboardDate: 'desc' }, + }); + + return { + members: offboardedMembers + .filter((m) => m.offboardingChecklistCompletions.length < totalEnabled) + .map((m) => ({ + memberId: m.id, + name: m.user.name, + email: m.user.email, + image: m.user.image, + offboardDate: m.offboardDate, + completedItems: m.offboardingChecklistCompletions.length, + totalItems: totalEnabled, + })), + }; + } + + private async seedDefaultsIfNeeded(organizationId: string) { + const count = await db.offboardingChecklistTemplate.count({ + where: { organizationId }, + }); + + if (count > 0) { + return; + } + + await db.offboardingChecklistTemplate.createMany({ + data: DEFAULT_OFFBOARDING_CHECKLIST_ITEMS.map((item) => ({ + organizationId, + title: item.title, + description: item.description, + evidenceRequired: item.evidenceRequired, + isAccessRevocation: item.isAccessRevocation, + sortOrder: item.sortOrder, + isDefault: true, + isEnabled: true, + })), + }); + } +} diff --git a/apps/api/src/offboarding-checklist/offboarding-export.service.spec.ts b/apps/api/src/offboarding-checklist/offboarding-export.service.spec.ts new file mode 100644 index 0000000000..ec342aa62b --- /dev/null +++ b/apps/api/src/offboarding-checklist/offboarding-export.service.spec.ts @@ -0,0 +1,250 @@ +import { PassThrough } from 'stream'; + +const mockDb = { + attachment: { + findFirst: jest.fn(), + }, +}; + +jest.mock('@db', () => ({ + db: mockDb, + AttachmentEntityType: { + offboarding_checklist: 'offboarding_checklist', + }, +})); + +jest.mock('archiver', () => { + const mockArchive = { + pipe: jest.fn(), + append: jest.fn(), + finalize: jest.fn().mockResolvedValue(undefined), + }; + return jest.fn(() => mockArchive); +}); + +import archiver from 'archiver'; +import { OffboardingExportService } from './offboarding-export.service'; + +describe('OffboardingExportService', () => { + const mockAttachmentsService = { + getObjectBuffer: jest.fn(), + }; + + const mockAccessRevocationService = { + getAccessRevocations: jest.fn(), + }; + + const mockChecklistService = { + getMemberChecklist: jest.fn(), + }; + + let service: OffboardingExportService; + let mockArchive: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + service = new OffboardingExportService( + mockAttachmentsService as never, + mockAccessRevocationService as never, + mockChecklistService as never, + ); + mockArchive = (archiver as unknown as jest.Mock)(); + }); + + it('creates a zip with summary CSV and vendor revocations CSV', async () => { + mockChecklistService.getMemberChecklist.mockResolvedValue({ + items: [ + { + title: 'Revoke system access', + completed: true, + completion: { + completedBy: { name: 'Jane Doe' }, + completedAt: new Date('2026-05-01'), + }, + evidence: [], + }, + { + title: 'Recover devices', + completed: false, + completion: null, + evidence: [], + }, + ], + totalItems: 2, + completedItems: 1, + }); + + mockAccessRevocationService.getAccessRevocations.mockResolvedValue({ + vendors: [ + { + vendorName: 'Slack', + revokedBy: { name: 'Jane Doe' }, + revokedAt: new Date('2026-05-01'), + evidence: [], + }, + ], + totalVendors: 1, + revokedCount: 1, + }); + + const output = new PassThrough(); + + await service.exportMemberEvidence({ + organizationId: 'org_1', + memberId: 'mem_1', + output, + }); + + expect(archiver).toHaveBeenCalledWith('zip', { zlib: { level: 9 } }); + expect(mockArchive.pipe).toHaveBeenCalledWith(output); + expect(mockArchive.finalize).toHaveBeenCalled(); + + const appendCalls = (mockArchive.append as jest.Mock).mock.calls; + expect(appendCalls).toHaveLength(2); + + // Summary CSV + const summaryCsv = appendCalls[0][0] as string; + expect(summaryCsv).toContain('Item,Status,Completed By'); + expect(summaryCsv).toContain('"Revoke system access",Complete,"Jane Doe"'); + expect(summaryCsv).toContain('"Recover devices",Pending,""'); + expect(appendCalls[0][1]).toEqual({ name: 'summary.csv' }); + + // Vendor revocations CSV + const vendorCsv = appendCalls[1][0] as string; + expect(vendorCsv).toContain('Vendor,Confirmed By,Date,Has Evidence'); + expect(vendorCsv).toContain('"Slack","Jane Doe",2026-05-01,No'); + expect(appendCalls[1][1]).toEqual({ + name: 'vendor-access-revocations/vendor-access-revocations.csv', + }); + }); + + it('includes vendor evidence files from S3', async () => { + mockChecklistService.getMemberChecklist.mockResolvedValue({ + items: [], + totalItems: 0, + completedItems: 0, + }); + + mockAccessRevocationService.getAccessRevocations.mockResolvedValue({ + vendors: [ + { + vendorName: 'AWS', + revokedBy: { name: 'Jane' }, + revokedAt: new Date('2026-05-01'), + evidence: [{ id: 'att_1', name: 'aws-disable.png' }], + }, + ], + totalVendors: 1, + revokedCount: 1, + }); + + mockDb.attachment.findFirst.mockResolvedValue({ + id: 'att_1', + url: 'org_1/attachments/offboarding/aws-disable.png', + }); + mockAttachmentsService.getObjectBuffer.mockResolvedValue( + Buffer.from('fake-png-data'), + ); + + const output = new PassThrough(); + await service.exportMemberEvidence({ + organizationId: 'org_1', + memberId: 'mem_1', + output, + }); + + const appendCalls = (mockArchive.append as jest.Mock).mock.calls; + const evidenceCall = appendCalls.find( + (c: unknown[]) => + (c[1] as { name: string }).name === + 'vendor-access-revocations/evidence/aws-disable.png', + ); + expect(evidenceCall).toBeDefined(); + expect(Buffer.isBuffer(evidenceCall[0])).toBe(true); + }); + + it('includes checklist item evidence in numbered folders', async () => { + mockChecklistService.getMemberChecklist.mockResolvedValue({ + items: [ + { + title: 'Recover devices', + completed: true, + completion: { + completedBy: { name: 'Jane' }, + completedAt: new Date(), + }, + evidence: [{ id: 'att_2', name: 'device-receipt.pdf' }], + }, + ], + totalItems: 1, + completedItems: 1, + }); + + mockAccessRevocationService.getAccessRevocations.mockResolvedValue({ + vendors: [], + totalVendors: 0, + revokedCount: 0, + }); + + mockDb.attachment.findFirst.mockResolvedValue({ + id: 'att_2', + url: 'org_1/attachments/offboarding/device-receipt.pdf', + }); + mockAttachmentsService.getObjectBuffer.mockResolvedValue( + Buffer.from('fake-pdf-data'), + ); + + const output = new PassThrough(); + await service.exportMemberEvidence({ + organizationId: 'org_1', + memberId: 'mem_1', + output, + }); + + const appendCalls = (mockArchive.append as jest.Mock).mock.calls; + const evidenceCall = appendCalls.find( + (c: unknown[]) => + (c[1] as { name: string }).name === + 'checklist-items/01-recover-devices/device-receipt.pdf', + ); + expect(evidenceCall).toBeDefined(); + }); + + it('skips evidence files that fail to download', async () => { + mockChecklistService.getMemberChecklist.mockResolvedValue({ + items: [ + { + title: 'Task with broken evidence', + completed: true, + completion: { + completedBy: { name: 'Jane' }, + completedAt: new Date(), + }, + evidence: [{ id: 'att_bad', name: 'missing.pdf' }], + }, + ], + totalItems: 1, + completedItems: 1, + }); + + mockAccessRevocationService.getAccessRevocations.mockResolvedValue({ + vendors: [], + totalVendors: 0, + revokedCount: 0, + }); + + mockDb.attachment.findFirst.mockResolvedValue(null); + + const output = new PassThrough(); + await service.exportMemberEvidence({ + organizationId: 'org_1', + memberId: 'mem_1', + output, + }); + + // Should have only 2 appends: summary CSV + vendor CSV (no evidence files) + const appendCalls = (mockArchive.append as jest.Mock).mock.calls; + expect(appendCalls).toHaveLength(2); + expect(mockArchive.finalize).toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/offboarding-checklist/offboarding-export.service.ts b/apps/api/src/offboarding-checklist/offboarding-export.service.ts new file mode 100644 index 0000000000..fd09c8e406 --- /dev/null +++ b/apps/api/src/offboarding-checklist/offboarding-export.service.ts @@ -0,0 +1,204 @@ +import { Injectable } from '@nestjs/common'; +import { db } from '@db'; +import archiver from 'archiver'; +import { AttachmentsService } from '../attachments/attachments.service'; +import { AccessRevocationService } from './access-revocation.service'; +import { OffboardingChecklistService } from './offboarding-checklist.service'; + +type ChecklistItems = Awaited< + ReturnType +>['items']; + +type VendorList = Awaited< + ReturnType +>['vendors']; + +@Injectable() +export class OffboardingExportService { + constructor( + private readonly attachmentsService: AttachmentsService, + private readonly accessRevocationService: AccessRevocationService, + private readonly offboardingChecklistService: OffboardingChecklistService, + ) {} + + async exportMemberEvidence({ + organizationId, + memberId, + output, + }: { + organizationId: string; + memberId: string; + output: NodeJS.WritableStream; + }) { + const archive = archiver('zip', { zlib: { level: 9 } }); + archive.pipe(output); + + const checklist = + await this.offboardingChecklistService.getMemberChecklist( + organizationId, + memberId, + ); + const revocations = + await this.accessRevocationService.getAccessRevocations( + organizationId, + memberId, + ); + + this.appendSummaryCsv(archive, checklist.items); + this.appendVendorRevocationsCsv(archive, revocations.vendors); + await this.appendVendorEvidence( + archive, + organizationId, + revocations.vendors, + ); + await this.appendChecklistEvidence( + archive, + organizationId, + checklist.items, + ); + + await archive.finalize(); + } + + private appendSummaryCsv( + archive: archiver.Archiver, + items: ChecklistItems, + prefix = '', + ) { + const rows = [ + 'Item,Status,Completed By,Completed Date,Evidence Count', + ...items.map((item) => { + const status = item.completed ? 'Complete' : 'Pending'; + const completedBy = item.completion?.completedBy?.name ?? ''; + const completedDate = item.completion?.completedAt + ? new Date(item.completion.completedAt).toISOString().split('T')[0] + : ''; + return `"${escapeCsvField(item.title)}",${status},"${escapeCsvField(completedBy)}",${completedDate},${item.evidence.length}`; + }), + ]; + archive.append(rows.join('\n'), { name: `${prefix}summary.csv` }); + } + + private appendVendorRevocationsCsv( + archive: archiver.Archiver, + vendors: VendorList, + prefix = '', + ) { + const rows = [ + 'Vendor,Confirmed By,Date,Has Evidence', + ...vendors.map((v) => { + const confirmedBy = v.revokedBy?.name ?? ''; + const date = v.revokedAt + ? new Date(v.revokedAt).toISOString().split('T')[0] + : ''; + const hasEvidence = (v.evidence?.length ?? 0) > 0 ? 'Yes' : 'No'; + return `"${escapeCsvField(v.vendorName)}","${escapeCsvField(confirmedBy)}",${date},${hasEvidence}`; + }), + ]; + archive.append(rows.join('\n'), { + name: `${prefix}vendor-access-revocations/vendor-access-revocations.csv`, + }); + } + + private async appendVendorEvidence( + archive: archiver.Archiver, + organizationId: string, + vendors: VendorList, + prefix = '', + ) { + for (const vendor of vendors) { + if (!vendor.evidence || vendor.evidence.length === 0) continue; + for (const file of vendor.evidence) { + const buffer = await this.getAttachmentBuffer(organizationId, file.id); + if (!buffer) continue; + archive.append(buffer, { + name: `${prefix}vendor-access-revocations/evidence/${file.name}`, + }); + } + } + } + + private async appendChecklistEvidence( + archive: archiver.Archiver, + organizationId: string, + items: ChecklistItems, + prefix = '', + ) { + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.evidence.length === 0) continue; + const folderNum = String(i + 1).padStart(2, '0'); + const folderName = item.title + .replace(/[^a-zA-Z0-9 ]/g, '') + .replace(/\s+/g, '-') + .toLowerCase(); + for (const file of item.evidence) { + const buffer = await this.getAttachmentBuffer(organizationId, file.id); + if (!buffer) continue; + archive.append(buffer, { + name: `${prefix}checklist-items/${folderNum}-${folderName}/${file.name}`, + }); + } + } + } + + async exportAllOffboardings({ + organizationId, + output, + }: { + organizationId: string; + output: NodeJS.WritableStream; + }) { + const archive = archiver('zip', { zlib: { level: 9 } }); + archive.pipe(output); + + const members = await db.member.findMany({ + where: { organizationId, offboardDate: { not: null }, deactivated: true }, + include: { user: { select: { name: true, email: true } } }, + orderBy: { offboardDate: 'desc' }, + }); + + for (const member of members) { + const safeName = (member.user.name ?? 'member') + .replace(/[^a-zA-Z0-9 ]/g, '') + .replace(/\s+/g, '-') + .toLowerCase(); + const prefix = `offboarded-employees/${safeName}/`; + + const checklist = await this.offboardingChecklistService.getMemberChecklist( + organizationId, + member.id, + ); + const revocations = await this.accessRevocationService.getAccessRevocations( + organizationId, + member.id, + ); + + this.appendSummaryCsv(archive, checklist.items, prefix); + this.appendVendorRevocationsCsv(archive, revocations.vendors, prefix); + await this.appendVendorEvidence(archive, organizationId, revocations.vendors, prefix); + await this.appendChecklistEvidence(archive, organizationId, checklist.items, prefix); + } + + await archive.finalize(); + } + + private async getAttachmentBuffer( + organizationId: string, + attachmentId: string, + ): Promise { + try { + const attachment = await db.attachment.findFirst({ + where: { id: attachmentId, organizationId }, + }); + if (!attachment) return null; + return await this.attachmentsService.getObjectBuffer(attachment.url); + } catch { + return null; + } + } +} + +function escapeCsvField(value: string): string { + return value.replace(/"/g, '""'); +} diff --git a/apps/api/src/people/dto/update-people.dto.ts b/apps/api/src/people/dto/update-people.dto.ts index 8987919ea8..268bf7101a 100644 --- a/apps/api/src/people/dto/update-people.dto.ts +++ b/apps/api/src/people/dto/update-people.dto.ts @@ -77,4 +77,22 @@ export class UpdatePeopleDto extends PartialType(CreatePeopleDto) { @IsString() @MaxLength(2000) backgroundCheckExemptJustification?: string; + + @ApiProperty({ + description: 'Employee onboard date', + example: '2026-01-15T00:00:00.000Z', + required: false, + }) + @IsOptional() + @IsDateString() + onboardDate?: string | null; + + @ApiProperty({ + description: 'Employee offboard date', + example: '2026-04-30T00:00:00.000Z', + required: false, + }) + @IsOptional() + @IsDateString() + offboardDate?: string | null; } diff --git a/apps/api/src/people/people.controller.spec.ts b/apps/api/src/people/people.controller.spec.ts index e93c62580d..b1751ad936 100644 --- a/apps/api/src/people/people.controller.spec.ts +++ b/apps/api/src/people/people.controller.spec.ts @@ -1,6 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { PeopleService } from './people.service'; import { PeopleInviteService } from './people-invite.service'; +import { AttachmentsService } from '../attachments/attachments.service'; import type { AuthContext } from '../auth/types'; import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; import { PermissionGuard } from '../auth/permission.guard'; @@ -83,6 +84,12 @@ describe('PeopleController', () => { inviteMembers: jest.fn(), }; + const mockAttachmentsService = { + getAttachments: jest.fn(), + uploadAttachment: jest.fn(), + deleteAttachment: jest.fn(), + }; + const mockGuard = { canActivate: jest.fn().mockReturnValue(true) }; const mockAuthContext: AuthContext = { @@ -101,6 +108,7 @@ describe('PeopleController', () => { providers: [ { provide: PeopleService, useValue: mockPeopleService }, { provide: PeopleInviteService, useValue: mockPeopleInviteService }, + { provide: AttachmentsService, useValue: mockAttachmentsService }, ], }) .overrideGuard(HybridAuthGuard) @@ -136,6 +144,7 @@ describe('PeopleController', () => { expect(peopleService.findAllByOrganization).toHaveBeenCalledWith( 'org_123', false, + undefined, ); }); @@ -147,6 +156,7 @@ describe('PeopleController', () => { expect(peopleService.findAllByOrganization).toHaveBeenCalledWith( 'org_123', true, + undefined, ); }); diff --git a/apps/api/src/people/people.controller.ts b/apps/api/src/people/people.controller.ts index e0ee6eee3b..aa506661f6 100644 --- a/apps/api/src/people/people.controller.ts +++ b/apps/api/src/people/people.controller.ts @@ -36,6 +36,9 @@ import { BulkCreatePeopleDto } from './dto/bulk-create-people.dto'; import { InvitePeopleDto } from './dto/invite-people.dto'; import { PeopleResponseDto, UserResponseDto } from './dto/people-responses.dto'; import { UpdateEmailPreferencesDto } from './dto/update-email-preferences.dto'; +import { AttachmentsService } from '../attachments/attachments.service'; +import { UploadAttachmentDto } from '../attachments/upload-attachment.dto'; +import { AttachmentEntityType } from '@db'; import { PeopleService } from './people.service'; import { PeopleInviteService } from './people-invite.service'; import { GET_ALL_PEOPLE_RESPONSES } from './schemas/get-all-people.responses'; @@ -58,8 +61,18 @@ export class PeopleController { constructor( private readonly peopleService: PeopleService, private readonly peopleInviteService: PeopleInviteService, + private readonly attachmentsService: AttachmentsService, ) {} + private resolveEventType(eventType: string): AttachmentEntityType { + if (eventType === 'onboard') return AttachmentEntityType.employment_onboard; + if (eventType === 'offboard') + return AttachmentEntityType.employment_offboard; + throw new BadRequestException( + `Invalid event type "${eventType}". Must be "onboard" or "offboard".`, + ); + } + @Post('invite') @RequirePermission('member', 'create') @ApiOperation({ summary: 'Invite members to the organization' }) @@ -99,10 +112,24 @@ export class PeopleController { @OrganizationId() organizationId: string, @AuthContext() authContext: AuthContextType, @Query('includeDeactivated') includeDeactivated?: string, + @Query('onboardAfter') onboardAfter?: string, + @Query('onboardBefore') onboardBefore?: string, + @Query('offboardAfter') offboardAfter?: string, + @Query('offboardBefore') offboardBefore?: string, ) { + const filters = { + ...(onboardAfter ? { onboardAfter: new Date(onboardAfter) } : {}), + ...(onboardBefore ? { onboardBefore: new Date(onboardBefore) } : {}), + ...(offboardAfter ? { offboardAfter: new Date(offboardAfter) } : {}), + ...(offboardBefore ? { offboardBefore: new Date(offboardBefore) } : {}), + }; + + const hasFilters = Object.keys(filters).length > 0; + const people = await this.peopleService.findAllByOrganization( organizationId, includeDeactivated === 'true', + hasFilters ? filters : undefined, ); return { @@ -529,6 +556,66 @@ export class PeopleController { }; } + @Get(':id/employment-evidence/:eventType') + @RequirePermission('member', 'read') + @ApiOperation({ summary: 'Get employment evidence attachments' }) + @ApiParam(PEOPLE_PARAMS.memberId) + @ApiParam({ name: 'eventType', enum: ['onboard', 'offboard'] }) + async getEmploymentEvidence( + @Param('id') memberId: string, + @Param('eventType') eventType: string, + @OrganizationId() organizationId: string, + ) { + const entityType = this.resolveEventType(eventType); + await this.peopleService.findById(memberId, organizationId); + return this.attachmentsService.getAttachments( + organizationId, + memberId, + entityType, + ); + } + + @Post(':id/employment-evidence/:eventType') + @RequirePermission('member', 'update') + @ApiOperation({ summary: 'Upload employment evidence' }) + @ApiParam(PEOPLE_PARAMS.memberId) + @ApiParam({ name: 'eventType', enum: ['onboard', 'offboard'] }) + async uploadEmploymentEvidence( + @Param('id') memberId: string, + @Param('eventType') eventType: string, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + @Body() uploadDto: UploadAttachmentDto, + ) { + const entityType = this.resolveEventType(eventType); + await this.peopleService.findById(memberId, organizationId); + return this.attachmentsService.uploadAttachment( + organizationId, + memberId, + entityType, + uploadDto, + authContext.userId, + ); + } + + @Delete(':id/employment-evidence/:eventType/:attachmentId') + @RequirePermission('member', 'delete') + @ApiOperation({ summary: 'Delete employment evidence' }) + @ApiParam(PEOPLE_PARAMS.memberId) + @ApiParam({ name: 'eventType', enum: ['onboard', 'offboard'] }) + @ApiParam({ name: 'attachmentId', description: 'Attachment ID' }) + async deleteEmploymentEvidence( + @Param('id') memberId: string, + @Param('eventType') eventType: string, + @Param('attachmentId') attachmentId: string, + @OrganizationId() organizationId: string, + ) { + this.resolveEventType(eventType); + await this.peopleService.findById(memberId, organizationId); + await this.attachmentsService.deleteAttachment(organizationId, attachmentId); + return { success: true }; + } + @Get('me/email-preferences') @ApiOperation({ summary: 'Get current user email notification preferences' }) async getEmailPreferences( diff --git a/apps/api/src/people/people.module.ts b/apps/api/src/people/people.module.ts index 8d26064f9f..9590239747 100644 --- a/apps/api/src/people/people.module.ts +++ b/apps/api/src/people/people.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; +import { AttachmentsModule } from '../attachments/attachments.module'; import { TimelinesModule } from '../timelines/timelines.module'; import { FleetService } from '../lib/fleet.service'; import { PeopleController } from './people.controller'; @@ -7,7 +8,7 @@ import { PeopleService } from './people.service'; import { PeopleInviteService } from './people-invite.service'; @Module({ - imports: [AuthModule, TimelinesModule], + imports: [AuthModule, AttachmentsModule, TimelinesModule], controllers: [PeopleController], providers: [PeopleService, PeopleInviteService, FleetService], exports: [PeopleService], diff --git a/apps/api/src/people/people.service.ts b/apps/api/src/people/people.service.ts index 09e712a772..3be40dd442 100644 --- a/apps/api/src/people/people.service.ts +++ b/apps/api/src/people/people.service.ts @@ -36,12 +36,19 @@ export class PeopleService { async findAllByOrganization( organizationId: string, includeDeactivated?: boolean, + filters?: { + onboardAfter?: Date; + onboardBefore?: Date; + offboardAfter?: Date; + offboardBefore?: Date; + }, ): Promise { try { await MemberValidator.validateOrganization(organizationId); const members = await MemberQueries.findAllByOrganization( organizationId, includeDeactivated, + filters, ); this.logger.log( @@ -395,7 +402,11 @@ export class PeopleService { await db.member.update({ where: { id: memberId, organizationId }, - data: { deactivated: true, isActive: false }, + data: { + deactivated: true, + isActive: false, + offboardDate: member.offboardDate ?? new Date(), + }, }); // Direct DB session deletion is correct here — the API server IS the auth server, diff --git a/apps/api/src/people/utils/member-queries.ts b/apps/api/src/people/utils/member-queries.ts index da7ecc92fa..d9f70cd356 100644 --- a/apps/api/src/people/utils/member-queries.ts +++ b/apps/api/src/people/utils/member-queries.ts @@ -23,6 +23,8 @@ export class MemberQueries { backgroundCheckExempt: true, backgroundCheckExemptReason: true, backgroundCheckExemptJustification: true, + onboardDate: true, + offboardDate: true, fleetDmLabelId: true, user: { select: { @@ -54,11 +56,33 @@ export class MemberQueries { static async findAllByOrganization( organizationId: string, includeDeactivated = false, + filters?: { + onboardAfter?: Date; + onboardBefore?: Date; + offboardAfter?: Date; + offboardBefore?: Date; + }, ): Promise { return db.member.findMany({ where: { organizationId, ...(includeDeactivated ? {} : { deactivated: false }), + ...(filters?.onboardAfter || filters?.onboardBefore + ? { + onboardDate: { + ...(filters.onboardAfter ? { gte: filters.onboardAfter } : {}), + ...(filters.onboardBefore ? { lte: filters.onboardBefore } : {}), + }, + } + : {}), + ...(filters?.offboardAfter || filters?.offboardBefore + ? { + offboardDate: { + ...(filters.offboardAfter ? { gte: filters.offboardAfter } : {}), + ...(filters.offboardBefore ? { lte: filters.offboardBefore } : {}), + }, + } + : {}), }, select: this.MEMBER_SELECT, orderBy: { createdAt: 'desc' }, @@ -112,7 +136,7 @@ export class MemberQueries { updateData: UpdatePeopleDto, ): Promise { // Separate user-level fields from member-level fields - const { name, email, createdAt, ...memberFields } = updateData; + const { name, email, createdAt, onboardDate, offboardDate, ...memberFields } = updateData; // Prepare member update data const updatePayload: any = { ...memberFields }; @@ -122,6 +146,13 @@ export class MemberQueries { updatePayload.createdAt = new Date(createdAt); } + if (onboardDate !== undefined) { + updatePayload.onboardDate = onboardDate ? new Date(onboardDate) : null; + } + if (offboardDate !== undefined) { + updatePayload.offboardDate = offboardDate ? new Date(offboardDate) : null; + } + // Handle fleetDmLabelId: convert undefined to null for database if ( memberFields.fleetDmLabelId === undefined && diff --git a/apps/app/src/app/(app)/[orgId]/overview/components/OffboardingBanner.tsx b/apps/app/src/app/(app)/[orgId]/overview/components/OffboardingBanner.tsx new file mode 100644 index 0000000000..09bf0e55b0 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/overview/components/OffboardingBanner.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { useApiSWR } from '@/hooks/use-api-swr'; +import { WarningAlt, Close } from '@trycompai/design-system/icons'; +import Link from 'next/link'; +import { useParams } from 'next/navigation'; +import { useState } from 'react'; + +interface PendingMember { + memberId: string; + name: string; +} + +interface PendingResponse { + members: PendingMember[]; +} + +export function OffboardingBanner() { + const params = useParams<{ orgId: string }>(); + const { data } = useApiSWR( + '/v1/offboarding-checklist/pending', + ); + const members = data?.data?.members ?? []; + const [dismissed, setDismissed] = useState(false); + + if (dismissed || members.length === 0) return null; + + const link = members.length === 1 + ? `/${params.orgId}/people/${members[0].memberId}?tab=offboarding` + : `/${params.orgId}/people`; + + return ( +
+
+ + + + {members.length} employee{members.length !== 1 ? 's' : ''} + {' '} + require{members.length === 1 ? 's' : ''} offboarding completion + +
+
+ + View details + + +
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/overview/components/Overview.tsx b/apps/app/src/app/(app)/[orgId]/overview/components/Overview.tsx index b5e1adcb57..1cdd083538 100644 --- a/apps/app/src/app/(app)/[orgId]/overview/components/Overview.tsx +++ b/apps/app/src/app/(app)/[orgId]/overview/components/Overview.tsx @@ -4,6 +4,7 @@ import { FrameworkEditorFramework, Policy, Task } from '@db'; import type { FrameworkInstanceWithControls } from '@/lib/types/framework'; import { ComplianceOverview } from './ComplianceOverview'; import { FrameworksOverview } from './FrameworksOverview'; +import { OffboardingBanner } from './OffboardingBanner'; import { ToDoOverview } from './ToDoOverview'; import { FrameworkInstanceWithComplianceScore } from './types'; @@ -69,40 +70,43 @@ export const Overview = ({ }); return ( -
- - - +
+ +
+ + + +
); }; diff --git a/apps/app/src/app/(app)/[orgId]/overview/components/ToDoOverview.test.tsx b/apps/app/src/app/(app)/[orgId]/overview/components/ToDoOverview.test.tsx index 2710326d8b..02413cab70 100644 --- a/apps/app/src/app/(app)/[orgId]/overview/components/ToDoOverview.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/overview/components/ToDoOverview.test.tsx @@ -74,6 +74,11 @@ vi.mock('lucide-react', () => ({ NotebookText: () => , Play: () => , Upload: () => , + UserMinus: () => , +})); + +vi.mock('@/hooks/use-api-swr', () => ({ + useApiSWR: () => ({ data: { data: { members: [] } }, isLoading: false }), })); import { ToDoOverview } from './ToDoOverview'; @@ -220,13 +225,16 @@ describe('ToDoOverview', () => { ).not.toBeInTheDocument(); }); - it('renders tab triggers for policies and tasks', () => { + it('renders tab triggers for policies, tasks, and offboarding', () => { setMockPermissions(ADMIN_PERMISSIONS); render(); expect(screen.getByTestId('tab-trigger-policies')).toBeInTheDocument(); expect(screen.getByTestId('tab-trigger-tasks')).toBeInTheDocument(); + expect( + screen.getByTestId('tab-trigger-offboarding'), + ).toBeInTheDocument(); }); }); }); diff --git a/apps/app/src/app/(app)/[orgId]/overview/components/ToDoOverview.tsx b/apps/app/src/app/(app)/[orgId]/overview/components/ToDoOverview.tsx index 22f77e6dcc..12ec8c4737 100644 --- a/apps/app/src/app/(app)/[orgId]/overview/components/ToDoOverview.tsx +++ b/apps/app/src/app/(app)/[orgId]/overview/components/ToDoOverview.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useApiSWR } from '@/hooks/use-api-swr'; import { Button } from '@trycompai/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@trycompai/ui/card'; import { ScrollArea } from '@trycompai/ui/scroll-area'; @@ -13,6 +14,7 @@ import { NotebookText, Play, Upload, + UserMinus, } from 'lucide-react'; import { usePermissions } from '@/hooks/use-permissions'; import Link from 'next/link'; @@ -21,6 +23,19 @@ import { useMemo, useState } from 'react'; import { toast } from 'sonner'; import { ConfirmActionDialog } from './ConfirmActionDialog'; +interface PendingOffboardingMember { + memberId: string; + name: string; + email: string; + offboardDate: string; + completedItems: number; + totalItems: number; +} + +interface PendingOffboardingResponse { + members: PendingOffboardingMember[]; +} + export function ToDoOverview({ totalPolicies, totalTasks, @@ -48,6 +63,11 @@ export function ToDoOverview({ const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); + const { data: pendingData } = useApiSWR( + '/v1/offboarding-checklist/pending', + ); + const pendingOffboardings = pendingData?.data?.members ?? []; + const isOnboardingInProgress = !!onboardingTriggerJobId; const formatStatus = (status: string) => { @@ -115,11 +135,15 @@ export function ToDoOverview({ 0 + ? 'offboarding' + : unpublishedPolicies.length === 0 + ? 'tasks' + : 'policies' } className="w-full" > - + Tasks ({remainingTasks}) + + + Offboarding ({pendingOffboardings.length}) + @@ -246,6 +277,54 @@ export function ToDoOverview({
)} + + + {pendingOffboardings.length === 0 ? ( +
+ + + No pending offboardings + +
+ ) : ( +
+ +
+ {pendingOffboardings.map((member, index) => ( +
+
+
+
+ +
+
+ + Complete offboarding for {member.name} + + + {member.completedItems}/{member.totalItems}{' '} + tasks done + +
+
+ +
+ {index < pendingOffboardings.length - 1 && ( +
+ )} +
+ ))} +
+ +
+ )} + diff --git a/apps/app/src/app/(app)/[orgId]/overview/components/TodosOverview.tsx b/apps/app/src/app/(app)/[orgId]/overview/components/TodosOverview.tsx new file mode 100644 index 0000000000..0253ca17fb --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/overview/components/TodosOverview.tsx @@ -0,0 +1,86 @@ +'use client'; + +import { useApiSWR } from '@/hooks/use-api-swr'; +import { Badge, Text } from '@trycompai/design-system'; +import { ArrowRight, Checkmark } from '@trycompai/design-system/icons'; +import Link from 'next/link'; +import { useParams } from 'next/navigation'; + +interface PendingMember { + memberId: string; + name: string; + email: string; + image: string | null; + offboardDate: string; + completedItems: number; + totalItems: number; +} + +interface PendingResponse { + members: PendingMember[]; +} + +export function TodosOverview() { + const params = useParams<{ orgId: string }>(); + const organizationId = params.orgId; + const { data, isLoading } = useApiSWR( + '/v1/offboarding-checklist/pending', + ); + const members = data?.data?.members ?? []; + + return ( +
+
+
+ Todos + {members.length > 0 && ( + {members.length} + )} +
+
+ +
+ {isLoading ? ( +
+ Loading... +
+ ) : members.length === 0 ? ( +
+
+ +
+ + All clear — no pending items + +
+ ) : ( +
+ {members.map((member, index) => ( +
+
+
+ + Complete offboarding for {member.name} + + + {member.completedItems}/{member.totalItems} tasks done + +
+ + + +
+ {index < members.length - 1 && ( +
+ )} +
+ ))} +
+ )} +
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/AccessRevocationList.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/AccessRevocationList.tsx new file mode 100644 index 0000000000..78aede33d5 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/AccessRevocationList.tsx @@ -0,0 +1,359 @@ +'use client'; + +import { useAccessRevocations } from '@/hooks/use-access-revocations'; +import { + Button, + InputGroup, + InputGroupAddon, + InputGroupInput, + Text, +} from '@trycompai/design-system'; +import { + Checkmark, + DocumentAttachment, + Search, +} from '@trycompai/design-system/icons'; +import { format } from 'date-fns'; +import { useMemo, useRef, useState } from 'react'; +import { toast } from 'sonner'; + +const MONOGRAM_COLORS = [ + 'bg-blue-500', + 'bg-green-500', + 'bg-purple-500', + 'bg-amber-500', + 'bg-red-500', + 'bg-teal-500', + 'bg-indigo-500', + 'bg-pink-500', +]; + +function getMonogramColor(name: string): string { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash); + } + return MONOGRAM_COLORS[Math.abs(hash) % MONOGRAM_COLORS.length]; +} + +interface AccessRevocationListProps { + memberId: string; + canEdit: boolean; + onRevocationChange?: () => void; +} + +export function AccessRevocationList({ + memberId, + canEdit, + onRevocationChange, +}: AccessRevocationListProps) { + const { revocations, isLoading, revokeAccess, undoRevocation, revokeAll } = + useAccessRevocations(memberId); + const [processingVendorId, setProcessingVendorId] = useState( + null, + ); + const [isConfirmingAll, setIsConfirmingAll] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + + const { remaining, revoked } = useMemo(() => { + if (!revocations) return { remaining: [], revoked: [] }; + const filtered = revocations.vendors.filter((v) => + v.vendorName.toLowerCase().includes(searchQuery.toLowerCase()), + ); + return { + remaining: filtered.filter((v) => !v.revoked), + revoked: filtered.filter((v) => v.revoked), + }; + }, [revocations, searchQuery]); + + const handleRevoke = async (vendorId: string, file?: File) => { + setProcessingVendorId(vendorId); + try { + await revokeAccess(vendorId, file ? { file } : undefined); + toast.success(file ? 'Access removal confirmed with evidence' : 'Access removal confirmed'); + onRevocationChange?.(); + } catch { + toast.error('Failed to confirm access removal'); + } finally { + setProcessingVendorId(null); + } + }; + + const handleUndo = async (vendorId: string) => { + setProcessingVendorId(vendorId); + try { + await undoRevocation(vendorId); + toast.success('Revocation undone'); + onRevocationChange?.(); + } catch { + toast.error('Failed to undo revocation'); + } finally { + setProcessingVendorId(null); + } + }; + + const handleConfirmAll = async () => { + setIsConfirmingAll(true); + try { + await revokeAll(); + toast.success('All vendor access removals confirmed'); + onRevocationChange?.(); + } catch { + toast.error('Failed to confirm all'); + } finally { + setIsConfirmingAll(false); + } + }; + + if (isLoading) { + return ( +
+ Loading vendor access list... +
+ ); + } + + if (!revocations || revocations.vendors.length === 0) { + return ( +
+ + No vendors configured. Add vendors to your organization to track + access revocation. + +
+ ); + } + + const allConfirmed = revocations.revokedCount === revocations.totalVendors; + + return ( +
+
+ + + + + setSearchQuery(e.target.value)} + /> + + {canEdit && !allConfirmed && ( +
+ +
+ )} +
+ + {remaining.length > 0 && ( + <> + +
+ {remaining.map((vendor) => ( + handleRevoke(vendor.vendorId, file)} + /> + ))} +
+ + )} + + {revoked.length > 0 && ( + <> + +
+ {revoked.map((vendor) => ( + handleUndo(vendor.vendorId)} + /> + ))} +
+ + )} + + {remaining.length === 0 && revoked.length === 0 && ( +
+ + {searchQuery ? 'No matching vendors' : 'No vendors found'} + +
+ )} +
+ ); +} + +function SectionHeader({ label, count }: { label: string; count: number }) { + return ( +
+ + {label} + · {count} + +
+ ); +} + +function VendorMark({ name, logoUrl }: { name: string; logoUrl?: string | null }) { + const [imgError, setImgError] = useState(false); + const color = getMonogramColor(name); + const letter = name.charAt(0).toUpperCase(); + + if (logoUrl && !imgError) { + return ( + {name} setImgError(true)} + /> + ); + } + + return ( +
+ {letter} +
+ ); +} + +interface VendorRowProps { + vendor: { vendorId: string; vendorName: string; logoUrl?: string | null }; + canEdit: boolean; + isProcessing: boolean; + onRevoke: (file?: File) => void; +} + +function VendorRow({ vendor, canEdit, isProcessing, onRevoke }: VendorRowProps) { + const fileInputRef = useRef(null); + + const handleFileSelected = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + onRevoke(file); + if (fileInputRef.current) fileInputRef.current.value = ''; + }; + + return ( +
+
+ + {vendor.vendorName} +
+ {canEdit && ( +
+
+ +
+ +
+ +
+
+ )} +
+ ); +} + +interface RevokedVendorRowProps { + vendor: { + vendorId: string; + vendorName: string; + logoUrl?: string | null; + revokedAt: string | null; + revokedBy: { id: string; name: string; email: string } | null; + evidence?: { id: string; name: string; downloadUrl: string }[]; + }; + canEdit: boolean; + isProcessing: boolean; + onUndo: () => void; +} + +function RevokedVendorRow({ + vendor, + canEdit, + isProcessing, + onUndo, +}: RevokedVendorRowProps) { + return ( +
+
+ + {vendor.vendorName} +
+
+ {vendor.evidence && vendor.evidence.length > 0 && ( + + + {vendor.evidence[0].name} + + )} + {vendor.revokedBy && vendor.revokedAt && ( + + {vendor.revokedBy.name} ·{' '} + {format(new Date(vendor.revokedAt), 'MMM d, yyyy')} + + )} + {canEdit && ( +
+ +
+ )} +
+ +
+
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx index 1a0e8e19f4..92cd35b742 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx @@ -10,8 +10,8 @@ import { TabsList, TabsTrigger, } from '@trycompai/design-system'; -import { useSearchParams } from 'next/navigation'; -import { useEffect, useState } from 'react'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import { useCallback, useState } from 'react'; import type { DeviceWithChecks, FleetPolicy, Host } from '../../devices/types'; import type { BackgroundCheckBillingStatus, BackgroundCheckRecord } from './backgroundCheckTypes'; import { EmployeeBackgroundCheck } from './EmployeeBackgroundCheck'; @@ -20,8 +20,16 @@ import { EmployeeDevice } from './EmployeeDevice'; import { EmployeePageHeader } from './EmployeePageHeader'; import { EmployeePolicies } from './EmployeePolicies'; import { EmployeeHipaaTraining, EmployeeTrainingVideos } from './EmployeeTraining'; +import { OffboardingChecklist } from './OffboardingChecklist'; -type EmployeeTab = 'details' | 'policies' | 'training' | 'hipaa' | 'device' | 'background-check'; +type EmployeeTab = + | 'details' + | 'policies' + | 'training' + | 'hipaa' + | 'device' + | 'offboarding' + | 'background-check'; interface EmployeeProps { employee: Member & { @@ -63,12 +71,43 @@ export function Employee({ memberBackgroundCheckExempt, }: EmployeeProps) { const searchParams = useSearchParams(); - const querySelectedTab: EmployeeTab = - backgroundCheckStepEnabled && - (searchParams.get('background_check_step') || searchParams.get('background_check_billing')) - ? 'background-check' - : 'details'; - const [activeTab, setActiveTab] = useState(querySelectedTab); + const pathname = usePathname(); + const router = useRouter(); + + const VALID_TABS: EmployeeTab[] = ['details', 'policies', 'training', 'hipaa', 'device', 'offboarding', 'background-check']; + + const resolveTab = (): EmployeeTab => { + if ( + backgroundCheckStepEnabled && + (searchParams.get('background_check_step') || searchParams.get('background_check_billing')) + ) { + return 'background-check'; + } + const tabParam = searchParams.get('tab'); + if (tabParam && VALID_TABS.includes(tabParam as EmployeeTab)) { + return tabParam as EmployeeTab; + } + return 'details'; + }; + + const activeTab = resolveTab(); + + const handleTabChange = useCallback( + (value: string) => { + const params = new URLSearchParams(searchParams.toString()); + if (value === 'details') { + params.delete('tab'); + } else { + params.set('tab', value); + } + params.delete('background_check_step'); + params.delete('background_check_billing'); + const query = params.toString(); + router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false }); + }, + [pathname, router, searchParams], + ); + const [memberExempt, setMemberExempt] = useState(memberBackgroundCheckExempt); const [lastSyncedExempt, setLastSyncedExempt] = useState(memberBackgroundCheckExempt); @@ -77,12 +116,6 @@ export function Employee({ setMemberExempt(memberBackgroundCheckExempt); } - useEffect(() => { - if (querySelectedTab === 'background-check') { - setActiveTab('background-check'); - } - }, [querySelectedTab]); - return ( { - if (value) setActiveTab(value as EmployeeTab); + if (value) handleTabChange(value); }} > @@ -111,6 +144,9 @@ export function Employee({ {backgroundCheckStepEnabled && ( Background Check )} + {employee.offboardDate && ( + Offboarding + )} @@ -142,6 +178,15 @@ export function Employee({ fleetPolicies={fleetPolicies} /> + {employee.offboardDate && ( + + + + )} {backgroundCheckStepEnabled && ( (employee.department ?? 'none'); const [status, setStatus] = useState(employee.isActive ? 'active' : 'inactive'); - const [joinDate, setJoinDate] = useState(new Date(employee.createdAt)); - const [datePickerOpen, setDatePickerOpen] = useState(false); + const [onboardDate, setOnboardDate] = useState( + employee.onboardDate ? new Date(employee.onboardDate) : undefined, + ); + const [offboardDate, setOffboardDate] = useState( + employee.offboardDate ? new Date(employee.offboardDate) : undefined, + ); + const [onboardDatePickerOpen, setOnboardDatePickerOpen] = useState(false); + const [offboardDatePickerOpen, setOffboardDatePickerOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); const api = useApi(); @@ -61,10 +67,15 @@ export const EmployeeDetails = ({ const jobTitleChanged = jobTitle !== (employee.jobTitle ?? ''); const departmentChanged = department !== (employee.department ?? 'none'); const statusChanged = status !== (employee.isActive ? 'active' : 'inactive'); - const dateChanged = joinDate.toISOString() !== new Date(employee.createdAt).toISOString(); + const onboardDateChanged = + (onboardDate?.toISOString() ?? null) !== + (employee.onboardDate ? new Date(employee.onboardDate).toISOString() : null); + const offboardDateChanged = + (offboardDate?.toISOString() ?? null) !== + (employee.offboardDate ? new Date(employee.offboardDate).toISOString() : null); - return nameChanged || jobTitleChanged || departmentChanged || statusChanged || dateChanged; - }, [name, jobTitle, department, status, joinDate, employee]); + return nameChanged || jobTitleChanged || departmentChanged || statusChanged || onboardDateChanged || offboardDateChanged; + }, [name, jobTitle, department, status, onboardDate, offboardDate, employee]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -78,8 +89,9 @@ export const EmployeeDetails = ({ name?: string; department?: string; isActive?: boolean; - createdAt?: string; jobTitle?: string; + onboardDate?: string | null; + offboardDate?: string | null; } = {}; if (name !== (employee.user.name ?? '')) { @@ -91,15 +103,26 @@ export const EmployeeDetails = ({ if (department !== employee.department) { updateData.department = department; } - if (joinDate.toISOString() !== new Date(employee.createdAt).toISOString()) { - updateData.createdAt = joinDate.toISOString(); - } const isActive = status === 'active'; if (isActive !== employee.isActive) { updateData.isActive = isActive; } + const onboardDateChanged = + (onboardDate?.toISOString() ?? null) !== + (employee.onboardDate ? new Date(employee.onboardDate).toISOString() : null); + if (onboardDateChanged) { + updateData.onboardDate = onboardDate ? onboardDate.toISOString() : null; + } + + const offboardDateChanged = + (offboardDate?.toISOString() ?? null) !== + (employee.offboardDate ? new Date(employee.offboardDate).toISOString() : null); + if (offboardDateChanged) { + updateData.offboardDate = offboardDate ? offboardDate.toISOString() : null; + } + if (Object.keys(updateData).length === 0) { toast.info('No changes to save'); return; @@ -207,32 +230,69 @@ export const EmployeeDetails = ({ - {/* Join Date Field */} + {/* Onboard Date Field */} + + + + + + + + { + setOnboardDate(date ?? undefined); + setOnboardDatePickerOpen(false); + }} + captionLayout="dropdown" + fromYear={2000} + toYear={new Date().getFullYear() + 1} + /> + + + + + {/* Offboard Date Field */} - + date && setJoinDate(date)} + selected={offboardDate} + onSelect={(date) => { + setOffboardDate(date ?? undefined); + setOffboardDatePickerOpen(false); + }} captionLayout="dropdown" fromYear={2000} - toYear={new Date().getFullYear()} - disabled={(date) => date > new Date()} + toYear={new Date().getFullYear() + 1} /> diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/OffboardingChecklist.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/OffboardingChecklist.tsx new file mode 100644 index 0000000000..a969ec099d --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/OffboardingChecklist.tsx @@ -0,0 +1,168 @@ +'use client'; + +import { useOffboardingChecklist } from '@/hooks/use-offboarding-checklist'; +import { HStack, Label, Section, Stack, Switch, Text } from '@trycompai/design-system'; +import { useCallback, useState } from 'react'; +import { toast } from 'sonner'; +import { OffboardingChecklistItem } from './OffboardingChecklistItem'; +import { OffboardingSummaryCard } from './OffboardingSummaryCard'; + +interface OffboardingChecklistProps { + memberId: string; + canEdit: boolean; + offboardDate: string; +} + +export function OffboardingChecklist({ + memberId, + canEdit, + offboardDate, +}: OffboardingChecklistProps) { + const { + checklist, + isLoading, + completeItem, + uncompleteItem, + uploadEvidence, + getDownloadUrl, + refreshChecklist, + } = useOffboardingChecklist(memberId); + + const [showOnlyRemaining, setShowOnlyRemaining] = useState(false); + + const handleComplete = useCallback( + async ({ templateItemId, file }: { templateItemId: string; file?: File }) => { + try { + await completeItem({ templateItemId, file }); + toast.success('Item completed'); + } catch { + toast.error('Failed to complete item'); + } + }, + [completeItem], + ); + + const handleUncomplete = useCallback( + async (templateItemId: string) => { + try { + await uncompleteItem(templateItemId); + toast.success('Item uncompleted'); + } catch { + toast.error('Failed to uncomplete item'); + } + }, + [uncompleteItem], + ); + + const handleUploadEvidence = useCallback( + async (templateItemId: string, file: File) => { + try { + await uploadEvidence(templateItemId, file); + toast.success('Evidence uploaded'); + } catch { + toast.error('Failed to upload evidence'); + } + }, + [uploadEvidence], + ); + + const handleDownload = useCallback( + async (attachmentId: string) => { + try { + const url = await getDownloadUrl(attachmentId); + window.open(url, '_blank'); + } catch { + toast.error('Failed to download file'); + } + }, + [getDownloadUrl], + ); + + if (isLoading) { + return ( +
+ Loading checklist... +
+ ); + } + + if (!checklist || checklist.items.length === 0) { + return ( +
+ + No checklist items configured. Add items in the offboarding checklist + settings. + +
+ ); + } + + const filteredItems = showOnlyRemaining + ? checklist.items.filter((item) => !item.completed) + : checklist.items; + + return ( + + {offboardDate && ( + item.evidence.length > 0)} + /> + )} + + +
+
+

Offboarding checklist

+

+ Track tasks required to complete this offboarding. +

+
+ + + + +
+ +
+ {filteredItems.map((item) => ( + refreshChecklist()} + /> + ))} + {filteredItems.length === 0 && showOnlyRemaining && ( +
+ + All tasks completed. Turn off the filter to see all items. + +
+ )} +
+
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/OffboardingChecklistItem.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/OffboardingChecklistItem.tsx new file mode 100644 index 0000000000..b84cd92778 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/OffboardingChecklistItem.tsx @@ -0,0 +1,409 @@ +'use client'; + +import type { ChecklistItem } from '@/hooks/use-offboarding-checklist'; +import { useAccessRevocations } from '@/hooks/use-access-revocations'; +import { + Badge, + Button, + Collapsible, + CollapsibleContent, + CollapsibleTrigger, + HStack, + Stack, + Text, +} from '@trycompai/design-system'; +import { + Checkmark, + ChevronDown, + DocumentDownload, + Upload, +} from '@trycompai/design-system/icons'; +import { useRef, useState } from 'react'; +import { AccessRevocationList } from './AccessRevocationList'; + +interface OffboardingChecklistItemProps { + item: ChecklistItem; + memberId: string; + canEdit: boolean; + onComplete: (args: { templateItemId: string; file?: File }) => Promise; + onUncomplete: (templateItemId: string) => Promise; + onUploadEvidence: (templateItemId: string, file: File) => Promise; + onDownload: (attachmentId: string) => Promise; + onChecklistRefresh?: () => void; +} + +function StatusCircle({ done, total }: { done: number; total: number }) { + const allDone = done === total && total > 0; + const partial = done > 0 && !allDone; + + if (allDone) { + return ( +
+ +
+ ); + } + if (partial) { + return ( +
+
+
+ ); + } + return ( +
+ ); +} + +function ChecklistStatusCircle({ + item, + memberId, +}: { + item: ChecklistItem; + memberId: string; +}) { + if (item.isAccessRevocation) { + return ; + } + const done = item.completed ? 1 : 0; + return ; +} + +function AccessRevocationStatusCircle({ memberId }: { memberId: string }) { + const { revocations } = useAccessRevocations(memberId); + if (!revocations) return ; + return ( + + ); +} + +function ItemBadges({ + item, +}: { + item: ChecklistItem; +}) { + return ( + <> + {item.isAccessRevocation && ( +
+ Critical +
+ )} + {item.evidenceRequired && ( +
+ Evidence +
+ )} + + ); +} + +function ItemProgress({ + item, + memberId, +}: { + item: ChecklistItem; + memberId: string; +}) { + if (item.isAccessRevocation) { + return ; + } + const done = item.completed ? 1 : 0; + const total = 1; + const pct = done / total; + return ( +
+ + {done}/{total} + +
+
+
+
+ ); +} + +function AccessRevocationProgress({ memberId }: { memberId: string }) { + const { revocations } = useAccessRevocations(memberId); + if (!revocations) return null; + const pct = + revocations.totalVendors > 0 + ? revocations.revokedCount / revocations.totalVendors + : 0; + return ( +
+ + {revocations.revokedCount}/{revocations.totalVendors} + +
+
+
+
+ ); +} + +export function OffboardingChecklistItem({ + item, + memberId, + canEdit, + onComplete, + onUncomplete, + onUploadEvidence, + onDownload, + onChecklistRefresh, +}: OffboardingChecklistItemProps) { + const [isOpen, setIsOpen] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); + const dropzoneInputRef = useRef(null); + + const handleComplete = async () => { + if (isProcessing) return; + setIsProcessing(true); + try { + await onComplete({ templateItemId: item.templateItemId }); + } finally { + setIsProcessing(false); + } + }; + + const handleUncomplete = async () => { + if (isProcessing) return; + setIsProcessing(true); + try { + await onUncomplete(item.templateItemId); + } finally { + setIsProcessing(false); + } + }; + + const handleFileDrop = async (e: React.DragEvent) => { + e.preventDefault(); + const file = e.dataTransfer.files[0]; + if (!file) return; + await handleFileUpload(file); + }; + + const handleFileSelect = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + await handleFileUpload(file); + if (dropzoneInputRef.current) dropzoneInputRef.current.value = ''; + }; + + const handleFileUpload = async (file: File) => { + if (isProcessing) return; + setIsProcessing(true); + try { + if (!item.completed) { + await onComplete({ templateItemId: item.templateItemId, file }); + } else { + await onUploadEvidence(item.templateItemId, file); + } + } finally { + setIsProcessing(false); + } + }; + + const isExpandable = + item.isAccessRevocation || item.evidenceRequired || canEdit; + + return ( + +
+ + +
+
+ {item.title} + +
+ {item.description && ( + + {item.description} + + )} +
+ {isExpandable && ( +
+ + +
+ )} +
+ + + {item.isAccessRevocation ? ( + + ) : ( +
+ {item.evidenceRequired ? ( + + ) : ( + + )} +
+ )} +
+
+
+ ); +} + +function SimpleContent({ + item, + canEdit, + isProcessing, + onComplete, + onUncomplete, +}: { + item: ChecklistItem; + canEdit: boolean; + isProcessing: boolean; + onComplete: () => void; + onUncomplete: () => void; +}) { + if (!canEdit) return null; + + return ( +
+ {item.completed ? ( + + ) : ( + + )} +
+ ); +} + +function EvidenceContent({ + item, + canEdit, + isProcessing, + dropzoneInputRef, + onFileDrop, + onFileSelect, + onDownload, + onUncomplete, +}: { + item: ChecklistItem; + canEdit: boolean; + isProcessing: boolean; + dropzoneInputRef: React.RefObject; + onFileDrop: (e: React.DragEvent) => void; + onFileSelect: (e: React.ChangeEvent) => void; + onDownload: (attachmentId: string) => void; + onUncomplete: () => void; +}) { + return ( + + {item.evidence.length > 0 && ( + + {item.evidence.map((file) => ( + + + {file.name} + +
+ +
+
+ ))} +
+ )} + + {canEdit && ( +
e.preventDefault()} + onClick={() => dropzoneInputRef.current?.click()} + className="flex cursor-pointer flex-col items-center gap-2 rounded-md border-2 border-dashed border-muted-foreground/25 px-4 py-6 text-center transition hover:border-muted-foreground/50 hover:bg-muted/25" + > + +
+ + {item.completed + ? 'Drop files here or click to add more evidence' + : 'Drop files here or click to upload proof and mark as complete'} + +
+ +
+ )} + + {item.completed && canEdit && ( +
+ +
+ )} +
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/OffboardingSummaryCard.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/OffboardingSummaryCard.tsx new file mode 100644 index 0000000000..cdd0cc7a5b --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/OffboardingSummaryCard.tsx @@ -0,0 +1,97 @@ +'use client'; + +import { Button } from '@trycompai/design-system'; +import { Download } from '@trycompai/design-system/icons'; +import { differenceInDays, format } from 'date-fns'; + +interface OffboardingSummaryCardProps { + memberId: string; + offboardDate: string; + totalItems: number; + completedItems: number; + hasEvidence: boolean; +} + +export function OffboardingSummaryCard({ + memberId, + offboardDate, + totalItems, + completedItems, + hasEvidence, +}: OffboardingSummaryCardProps) { + const daysSince = differenceInDays(new Date(), new Date(offboardDate)); + const progressPercent = + totalItems > 0 ? Math.round((completedItems / totalItems) * 100) : 0; + + return ( +
+
+
+ + Offboarding progress + +
+
+ + {completedItems} + + + / {totalItems} tasks + +
+
+
+
+
+
+
+ + Termination date + +
+

+ {format(new Date(offboardDate), 'MMM d, yyyy')} +

+
+
+
+ + Days since + +
+

+ {daysSince} +

+
+
+
+ + Completion + +
+

+ {progressPercent}% +

+
+
+ +
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx index e3e49b92fc..5cb8463ebd 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx @@ -1,5 +1,6 @@ 'use client'; +import { format } from 'date-fns'; import Link from 'next/link'; import { useParams } from 'next/navigation'; import { useState } from 'react'; @@ -327,6 +328,28 @@ export function MemberRow({
+ {/* ONBOARDED */} + + {member.onboardDate ? ( + + {format(member.onboardDate, 'MMM d, yyyy')} + + ) : ( + + )} + + + {/* OFFBOARDED */} + + {member.offboardDate ? ( + + {format(member.offboardDate, 'MMM d, yyyy')} + + ) : ( + + )} + + {/* TASKS */} {taskItems.length > 0 ? ( diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/PendingInvitationRow.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/PendingInvitationRow.tsx index fc7b670f17..a8183d43f7 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/PendingInvitationRow.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/PendingInvitationRow.tsx @@ -100,6 +100,16 @@ export function PendingInvitationRow({
+ {/* ONBOARDED */} + + + + + {/* OFFBOARDED */} + + + + {/* TASKS */} 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 923e87d054..c15587e681 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 @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'; import { useState } from 'react'; import { toast } from 'sonner'; +import { format } from 'date-fns'; import { useApi } from '@/hooks/use-api'; import { usePeopleActions } from '@/hooks/use-people-api'; import { parseRolesString } from '@/lib/permissions'; @@ -12,6 +13,8 @@ import { authClient } from '@/utils/auth-client'; import useSWR from 'swr'; import type { Invitation } from '@db'; import { + Button, + Calendar, Empty, EmptyDescription, EmptyHeader, @@ -19,6 +22,9 @@ import { InputGroup, InputGroupAddon, InputGroupInput, + Popover, + PopoverContent, + PopoverTrigger, Select, SelectContent, SelectItem, @@ -32,7 +38,7 @@ import { TableHeader, TableRow, } from '@trycompai/design-system'; -import { InProgress, Search } from '@trycompai/design-system/icons'; +import { Calendar as CalendarIcon, ChevronDown, InProgress, Search } from '@trycompai/design-system/icons'; import { apiClient } from '@/lib/api-client'; import { useMemo } from 'react'; @@ -81,6 +87,10 @@ export function TeamMembersClient({ const [searchQuery, setSearchQuery] = useState(''); const [roleFilter, setRoleFilter] = useState(''); const [statusFilter, setStatusFilter] = useState(''); + const [onboardFrom, setOnboardFrom] = useState(); + const [onboardTo, setOnboardTo] = useState(); + const [offboardFrom, setOffboardFrom] = useState(); + const [offboardTo, setOffboardTo] = useState(); const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(25); @@ -128,15 +138,47 @@ export function TeamMembersClient({ }; const allItems = buildDisplayItems(data); + const hasOffboardFilter = !!(offboardFrom || offboardTo); + const effectiveStatusFilter = hasOffboardFilter && !statusFilter ? 'all' : statusFilter; const filteredItems = filterDisplayItems({ items: allItems, searchQuery, roleFilter, - statusFilter, + statusFilter: effectiveStatusFilter, }); - const activeMembers = filteredItems.filter((item) => item.type === 'member'); - const pendingInvites = filteredItems.filter((item) => item.type === 'invitation'); + const dateFilteredItems = filteredItems.filter((item) => { + if (item.type !== 'member') return true; + const member = item as MemberWithUser; + + if (onboardFrom || onboardTo) { + if (!member.onboardDate) return false; + const onboard = member.onboardDate; + const d = new Date(onboard); + if (onboardFrom && d < onboardFrom) return false; + if (onboardTo) { + const end = new Date(onboardTo); + end.setHours(23, 59, 59, 999); + if (d > end) return false; + } + } + + if (offboardFrom || offboardTo) { + if (!member.offboardDate) return false; + const d = new Date(member.offboardDate); + if (offboardFrom && d < offboardFrom) return false; + if (offboardTo) { + const end = new Date(offboardTo); + end.setHours(23, 59, 59, 999); + if (d > end) return false; + } + } + + return true; + }); + + const activeMembers = dateFilteredItems.filter((item) => item.type === 'member'); + const pendingInvites = dateFilteredItems.filter((item) => item.type === 'invitation'); // Combine all items for table display const allDisplayItems = [...activeMembers, ...pendingInvites]; @@ -254,7 +296,9 @@ export function TeamMembersClient({ }} > - + + {{ all: 'All People', active: 'Active', pending: 'Pending', deactivated: 'Deactivated' }[statusFilter] ?? 'Active'} + All People @@ -274,7 +318,9 @@ export function TeamMembersClient({ }} > - + + {{ owner: 'Owner', admin: 'Admin', auditor: 'Auditor', employee: 'Employee', contractor: 'Contractor' }[roleFilter] ?? 'All Roles'} + All Roles @@ -286,6 +332,20 @@ export function TeamMembersClient({
+ { setOnboardFrom(from); setOnboardTo(to); setPage(1); }} + onClear={() => { setOnboardFrom(undefined); setOnboardTo(undefined); setPage(1); }} + /> + { setOffboardFrom(from); setOffboardTo(to); setPage(1); }} + onClear={() => { setOffboardFrom(undefined); setOffboardTo(undefined); setPage(1); }} + /> {hasAnyConnection && (
@@ -462,6 +522,8 @@ export function TeamMembersClient({
ROLE
+ ONBOARDED + OFFBOARDED TASKS ACTIONS @@ -503,3 +565,175 @@ export function TeamMembersClient({ ); } + +const PRESETS = [ + { label: 'Last 7 days', days: 7 }, + { label: 'Last 30 days', days: 30 }, + { label: 'This quarter', days: 90 }, + { label: 'This year', days: 365 }, + { label: 'All time', days: 0 }, +] as const; + +function getPresetRange(days: number): { from: Date | undefined; to: Date | undefined } { + if (days === 0) return { from: undefined, to: undefined }; + const to = new Date(); + const from = new Date(); + from.setDate(from.getDate() - days); + return { from, to }; +} + +function getActivePresetLabel(from: Date | undefined, to: Date | undefined): string | null { + if (!from && !to) return 'Any time'; + if (!from || !to) return null; + const diffDays = Math.round((to.getTime() - from.getTime()) / (1000 * 60 * 60 * 24)); + const now = new Date(); + const isToToday = Math.abs(to.getTime() - now.getTime()) < 1000 * 60 * 60 * 24; + if (!isToToday) return null; + for (const p of PRESETS) { + if (p.days === 0) continue; + if (Math.abs(diffDays - p.days) <= 1) return p.label; + } + return null; +} + +function DateRangeFilter({ + label, + from, + to, + onApply, + onClear, +}: { + label: string; + from: Date | undefined; + to: Date | undefined; + onApply: (from: Date | undefined, to: Date | undefined) => void; + onClear: () => void; +}) { + const [open, setOpen] = useState(false); + const [draftFrom, setDraftFrom] = useState(from); + const [draftTo, setDraftTo] = useState(to); + const [activePreset, setActivePreset] = useState(null); + const [fromPickerOpen, setFromPickerOpen] = useState(false); + const [toPickerOpen, setToPickerOpen] = useState(false); + + const handleOpenChange = (isOpen: boolean) => { + if (isOpen) { + setDraftFrom(from); + setDraftTo(to); + setActivePreset(getActivePresetLabel(from, to)); + } + setOpen(isOpen); + }; + + const handlePreset = (days: number, presetLabel: string) => { + const range = getPresetRange(days); + setDraftFrom(range.from); + setDraftTo(range.to); + setActivePreset(presetLabel); + }; + + const handleApply = () => { + onApply(draftFrom, draftTo); + setOpen(false); + }; + + const handleClear = () => { + onClear(); + setOpen(false); + }; + + const displayLabel = from && to + ? `${format(from, 'MMM d')} – ${format(to, 'MMM d, yyyy')}` + : from + ? `From ${format(from, 'MMM d, yyyy')}` + : 'Any time'; + + return ( +
+ + +
+ + {label} + · + {displayLabel} + +
+
+ +
+ + {label} between + + +
+ {PRESETS.map((p) => ( + + ))} +
+ +
+ + +
+ + {draftFrom ? format(draftFrom, 'MMM d, yyyy') : Start date} +
+
+ + { setDraftFrom(d ?? undefined); setActivePreset(null); setFromPickerOpen(false); }} + captionLayout="dropdown" + fromYear={2000} + toYear={new Date().getFullYear() + 1} + /> + +
+ + + +
+ + {draftTo ? format(draftTo, 'MMM d, yyyy') : End date} +
+
+ + { setDraftTo(d ?? undefined); setActivePreset(null); setToPickerOpen(false); }} + captionLayout="dropdown" + fromYear={2000} + toYear={new Date().getFullYear() + 1} + /> + +
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx b/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx index 95584cb613..765eae532a 100644 --- a/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx @@ -2,6 +2,11 @@ import { Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + HStack, PageHeader, PageLayout, Tabs, @@ -9,7 +14,7 @@ import { TabsList, TabsTrigger, } from '@trycompai/design-system'; -import { Add } from '@trycompai/design-system/icons'; +import { Add, Download, OverflowMenuVertical } from '@trycompai/design-system/icons'; import type { Role } from '@db'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import type { ReactNode } from 'react'; @@ -138,13 +143,34 @@ export function PeoplePageTabs({ } actions={ - + +
+ +
+ + } + > + + + + { + window.open('/api/offboarding-export?all=true', '_blank'); + }} + > + + Export offboarding + + + +
} /> } diff --git a/apps/app/src/app/(app)/[orgId]/people/settings/components/OffboardingChecklistSettings.tsx b/apps/app/src/app/(app)/[orgId]/people/settings/components/OffboardingChecklistSettings.tsx new file mode 100644 index 0000000000..085072297b --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/settings/components/OffboardingChecklistSettings.tsx @@ -0,0 +1,363 @@ +'use client'; + +import { useState } from 'react'; +import { toast } from 'sonner'; +import { usePermissions } from '@/hooks/use-permissions'; +import { useApi } from '@/hooks/use-api'; +import { useApiSWR } from '@/hooks/use-api-swr'; +import { + Button, + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, + HStack, + Input, + Label, + Section, + Stack, + Switch, + Text, + Textarea, +} from '@trycompai/design-system'; +import { Add, TrashCan } from '@trycompai/design-system/icons'; + +interface TemplateItem { + id: string; + title: string; + description: string | null; + evidenceRequired: boolean; + sortOrder: number; + isDefault: boolean; + isEnabled: boolean; +} + +const TEMPLATE_ENDPOINT = '/v1/offboarding-checklist/template'; + +export function OffboardingChecklistSettings() { + const { hasPermission } = usePermissions(); + const canUpdate = hasPermission('organization', 'update'); + const { post, patch, delete: deleteReq } = useApi(); + + const { data, mutate } = useApiSWR(TEMPLATE_ENDPOINT); + const items = Array.isArray(data?.data) ? data.data : []; + + const [dialogOpen, setDialogOpen] = useState(false); + + const handleToggleEnabled = async ({ + item, + next, + }: { + item: TemplateItem; + next: boolean; + }) => { + const previous = items; + mutate( + (current) => { + if (!current) return current; + return { + ...current, + data: Array.isArray(current.data) + ? current.data.map((i) => + i.id === item.id ? { ...i, isEnabled: next } : i, + ) + : current.data, + }; + }, + { revalidate: false }, + ); + + const res = await patch( + `${TEMPLATE_ENDPOINT}/${item.id}`, + { isEnabled: next }, + ); + + if (res.error) { + mutate( + (current) => { + if (!current) return current; + return { + ...current, + data: previous, + }; + }, + { revalidate: false }, + ); + toast.error('Failed to update checklist item'); + return; + } + + toast.success(next ? 'Checklist item enabled' : 'Checklist item disabled'); + }; + + const handleToggleEvidence = async ({ + item, + next, + }: { + item: TemplateItem; + next: boolean; + }) => { + mutate( + (current) => { + if (!current) return current; + return { + ...current, + data: Array.isArray(current.data) + ? current.data.map((i) => + i.id === item.id ? { ...i, evidenceRequired: next } : i, + ) + : current.data, + }; + }, + { revalidate: false }, + ); + + const res = await patch( + `${TEMPLATE_ENDPOINT}/${item.id}`, + { evidenceRequired: next }, + ); + + if (res.error) { + mutate(); + toast.error('Failed to update evidence requirement'); + return; + } + + toast.success( + next ? 'Evidence now required' : 'Evidence no longer required', + ); + }; + + const handleDelete = async ({ item }: { item: TemplateItem }) => { + mutate( + (current) => { + if (!current) return current; + return { + ...current, + data: Array.isArray(current.data) + ? current.data.filter((i) => i.id !== item.id) + : current.data, + }; + }, + { revalidate: false }, + ); + + const res = await deleteReq(`${TEMPLATE_ENDPOINT}/${item.id}`); + + if (res.error) { + mutate(); + toast.error('Failed to delete checklist item'); + return; + } + + toast.success('Checklist item deleted'); + }; + + return ( +
+ +
+ + Configure the default checklist items for employee offboarding. + + {canUpdate && ( + + }> + + Add item + + { + setDialogOpen(false); + mutate(); + }} + /> + + )} +
+ + {items.length === 0 ? ( +
+ + No checklist items configured yet. + +
+ ) : ( + + {items.map((item) => ( + + ))} + + )} +
+
+ ); +} + +function ChecklistItemCard({ + item, + canUpdate, + onToggleEnabled, + onToggleEvidence, + onDelete, +}: { + item: TemplateItem; + canUpdate: boolean; + onToggleEnabled: (args: { item: TemplateItem; next: boolean }) => void; + onToggleEvidence: (args: { item: TemplateItem; next: boolean }) => void; + onDelete: (args: { item: TemplateItem }) => void; +}) { + return ( +
+
+ + {item.title} + {item.isDefault && ( +
+ Default +
+ )} +
+ {item.description ? ( + + {item.description} + + ) : null} +
+ + + + onToggleEvidence({ item, next: Boolean(next) }) + } + aria-label={`Evidence required for ${item.title}`} + /> + +
+
+ + + onToggleEnabled({ item, next: Boolean(next) }) + } + aria-label={`Enable ${item.title}`} + /> + {!item.isDefault && canUpdate && ( + + )} + +
+ ); +} + +function AddChecklistItemDialog({ + onCreated, +}: { + onCreated: () => void; +}) { + const { post } = useApi(); + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [evidenceRequired, setEvidenceRequired] = useState(false); + const [saving, setSaving] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!title.trim()) return; + + setSaving(true); + + const res = await post(TEMPLATE_ENDPOINT, { + title: title.trim(), + description: description.trim() || undefined, + evidenceRequired, + }); + + setSaving(false); + + if (res.error) { + toast.error('Failed to create checklist item'); + return; + } + + toast.success('Checklist item created'); + setTitle(''); + setDescription(''); + setEvidenceRequired(false); + onCreated(); + }; + + return ( + +
+ + Add checklist item + + +
+ + setTitle(e.target.value)} + placeholder="e.g., Return company laptop" + required + /> +
+
+ +