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/services/generic-employee-sync.service.ts b/apps/api/src/integration-platform/services/generic-employee-sync.service.ts index 9ee3c0c22c..1701daca73 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 @@ -294,7 +294,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..4ad2422d81 --- /dev/null +++ b/apps/api/src/offboarding-checklist/access-revocation.service.ts @@ -0,0 +1,207 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { db } from '@db'; + +@Injectable() +export class AccessRevocationService { + async getAccessRevocations(organizationId: string, memberId: string) { + const vendors = await db.vendor.findMany({ + where: { organizationId }, + select: { id: true, name: 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 = vendors.map((vendor) => { + const revocation = revocationMap.get(vendor.id); + return { + vendorId: vendor.id, + vendorName: vendor.name, + revoked: !!revocation, + revokedAt: revocation?.revokedAt ?? null, + revokedBy: revocation?.revokedBy ?? null, + notes: revocation?.notes ?? null, + }; + }); + + return { + vendors: vendorList, + totalVendors: vendors.length, + revokedCount: revocations.length, + }; + } + + async revokeVendorAccess({ + organizationId, + memberId, + vendorId, + revokedById, + notes, + }: { + organizationId: string; + memberId: string; + vendorId: string; + revokedById: string; + notes?: 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 } }, + }, + }); + + 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..ab989ea56b --- /dev/null +++ b/apps/api/src/offboarding-checklist/offboarding-checklist.controller.ts @@ -0,0 +1,207 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + UseGuards, +} from '@nestjs/common'; +import { ApiOperation, ApiParam, ApiSecurity, ApiTags } from '@nestjs/swagger'; +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 { 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, + ) {} + + @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, + ); + } + + @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/: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 }, + ) { + return this.offboardingChecklistService.revokeVendorAccess({ + organizationId, + memberId, + vendorId, + revokedById: authContext.userId!, + notes: body?.notes, + }); + } + + @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, + }); + } + + @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!, + }); + } +} 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..19dcc3eece --- /dev/null +++ b/apps/api/src/offboarding-checklist/offboarding-checklist.module.ts @@ -0,0 +1,14 @@ +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'; + +@Module({ + imports: [AuthModule, AttachmentsModule], + controllers: [OffboardingChecklistController], + providers: [OffboardingChecklistService, AccessRevocationService], + 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..575a7566dc --- /dev/null +++ b/apps/api/src/offboarding-checklist/offboarding-checklist.service.ts @@ -0,0 +1,335 @@ +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, + 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; + }) { + 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); + } + + 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/people/dto/update-people.dto.ts b/apps/api/src/people/dto/update-people.dto.ts index 951118b401..58b11dc533 100644 --- a/apps/api/src/people/dto/update-people.dto.ts +++ b/apps/api/src/people/dto/update-people.dto.ts @@ -54,4 +54,22 @@ export class UpdatePeopleDto extends PartialType(CreatePeopleDto) { @IsOptional() @IsBoolean() backgroundCheckExempt?: boolean; + + @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 05ced2524c..aff280ee0d 100644 --- a/apps/api/src/people/utils/member-queries.ts +++ b/apps/api/src/people/utils/member-queries.ts @@ -21,6 +21,8 @@ export class MemberQueries { isActive: true, deactivated: true, backgroundCheckExempt: true, + onboardDate: true, + offboardDate: true, fleetDmLabelId: true, user: { select: { @@ -52,11 +54,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' }, @@ -110,7 +134,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 }; @@ -120,6 +144,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]/people/[employeeId]/components/AccessRevocationList.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/AccessRevocationList.tsx new file mode 100644 index 0000000000..f633739ef3 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/AccessRevocationList.tsx @@ -0,0 +1,314 @@ +'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, 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) => { + setProcessingVendorId(vendorId); + try { + await revokeAccess(vendorId); + toast.success('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)} + /> + ))} +
+ + )} + + {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 }: { name: string }) { + const color = getMonogramColor(name); + const letter = name.charAt(0).toUpperCase(); + return ( +
+ {letter} +
+ ); +} + +interface VendorRowProps { + vendor: { vendorId: string; vendorName: string }; + canEdit: boolean; + isProcessing: boolean; + onRevoke: () => void; +} + +function VendorRow({ vendor, canEdit, isProcessing, onRevoke }: VendorRowProps) { + return ( +
+
+ + {vendor.vendorName} +
+ {canEdit && ( +
+ +
+ +
+
+ )} +
+ ); +} + +interface RevokedVendorRowProps { + vendor: { + vendorId: string; + vendorName: string; + revokedAt: string | null; + revokedBy: { id: string; name: string; email: string } | null; + }; + canEdit: boolean; + isProcessing: boolean; + onUndo: () => void; +} + +function RevokedVendorRow({ + vendor, + canEdit, + isProcessing, + onUndo, +}: RevokedVendorRowProps) { + return ( +
+
+ + {vendor.vendorName} +
+
+ {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..9bd3062ae0 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); }} > @@ -108,6 +141,9 @@ export function Employee({ Training Videos {hasHipaaFramework && HIPAA Training} Device + {employee.offboardDate && ( + Offboarding + )} {backgroundCheckStepEnabled && ( Background Check )} @@ -142,6 +178,15 @@ export function Employee({ fleetPolicies={fleetPolicies} /> + {employee.offboardDate && ( + + + + )} {backgroundCheckStepEnabled && ( (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(); @@ -62,9 +70,15 @@ export const EmployeeDetails = ({ 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 || dateChanged || onboardDateChanged || offboardDateChanged; + }, [name, jobTitle, department, status, joinDate, onboardDate, offboardDate, employee]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -80,6 +94,8 @@ export const EmployeeDetails = ({ isActive?: boolean; createdAt?: string; jobTitle?: string; + onboardDate?: string | null; + offboardDate?: string | null; } = {}; if (name !== (employee.user.name ?? '')) { @@ -100,6 +116,20 @@ export const EmployeeDetails = ({ 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; @@ -237,6 +267,74 @@ export const EmployeeDetails = ({ + + {/* Onboard Date Field */} + + + + + + + + { + setOnboardDate(date ?? undefined); + setOnboardDatePickerOpen(false); + }} + captionLayout="dropdown" + fromYear={2000} + toYear={new Date().getFullYear() + 1} + /> + + + + + {/* Offboard Date Field */} + + + + + + + + { + setOffboardDate(date ?? undefined); + setOffboardDatePickerOpen(false); + }} + captionLayout="dropdown" + fromYear={2000} + 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..efe230edcb --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/OffboardingChecklist.tsx @@ -0,0 +1,166 @@ +'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 && ( + + )} + + +
+
+

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..42261ba64a --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/OffboardingChecklistItem.tsx @@ -0,0 +1,408 @@ +'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) => { + 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..917e48baeb --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/OffboardingSummaryCard.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { differenceInDays, format } from 'date-fns'; + +interface OffboardingSummaryCardProps { + offboardDate: string; + totalItems: number; + completedItems: number; +} + +export function OffboardingSummaryCard({ + offboardDate, + totalItems, + completedItems, +}: 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..1c8573c179 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,24 @@ export function MemberRow({
+ {/* ONBOARDED */} + + + {format(member.onboardDate ?? member.createdAt, '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..0ae344c842 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 @@ -254,7 +254,9 @@ export function TeamMembersClient({ }} > - + + {{ all: 'All People', active: 'Active', pending: 'Pending', deactivated: 'Deactivated' }[statusFilter] ?? 'Active'} + All People @@ -274,7 +276,9 @@ export function TeamMembersClient({ }} > - + + {{ owner: 'Owner', admin: 'Admin', auditor: 'Auditor', employee: 'Employee', contractor: 'Contractor' }[roleFilter] ?? 'All Roles'} + All Roles @@ -462,6 +466,8 @@ export function TeamMembersClient({
ROLE
+ ONBOARDED + OFFBOARDED TASKS ACTIONS 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 + /> +
+
+ +