From 97d06f41a737c0796a2143429348b5a278fdbc2d Mon Sep 17 00:00:00 2001 From: Mariano Date: Fri, 8 May 2026 15:26:45 +0100 Subject: [PATCH 01/50] feat(db): add onboardDate, offboardDate fields and employment attachment types Co-Authored-By: Claude Opus 4.6 (1M context) --- .../migration.sql | 14 ++++++++++++++ packages/db/prisma/schema/attachments.prisma | 2 ++ packages/db/prisma/schema/auth.prisma | 2 ++ 3 files changed, 18 insertions(+) create mode 100644 packages/db/prisma/migrations/20260508142632_add_employment_event_fields/migration.sql diff --git a/packages/db/prisma/migrations/20260508142632_add_employment_event_fields/migration.sql b/packages/db/prisma/migrations/20260508142632_add_employment_event_fields/migration.sql new file mode 100644 index 0000000000..8f999f337f --- /dev/null +++ b/packages/db/prisma/migrations/20260508142632_add_employment_event_fields/migration.sql @@ -0,0 +1,14 @@ +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "AttachmentEntityType" ADD VALUE 'employment_onboard'; +ALTER TYPE "AttachmentEntityType" ADD VALUE 'employment_offboard'; + +-- AlterTable +ALTER TABLE "Member" ADD COLUMN "offboardDate" TIMESTAMP(3), +ADD COLUMN "onboardDate" TIMESTAMP(3); diff --git a/packages/db/prisma/schema/attachments.prisma b/packages/db/prisma/schema/attachments.prisma index 8f636dbc73..e7789e7dd0 100644 --- a/packages/db/prisma/schema/attachments.prisma +++ b/packages/db/prisma/schema/attachments.prisma @@ -27,6 +27,8 @@ enum AttachmentEntityType { trust_nda task_item background_check + employment_onboard + employment_offboard } enum AttachmentType { diff --git a/packages/db/prisma/schema/auth.prisma b/packages/db/prisma/schema/auth.prisma index 801083b868..cf865ac4ba 100644 --- a/packages/db/prisma/schema/auth.prisma +++ b/packages/db/prisma/schema/auth.prisma @@ -119,6 +119,8 @@ model Member { isActive Boolean @default(true) deactivated Boolean @default(false) backgroundCheckExempt Boolean @default(false) + onboardDate DateTime? + offboardDate DateTime? externalUserId String? externalUserSource String? employeeTrainingVideoCompletion EmployeeTrainingVideoCompletion[] From c1630497aed1532513c785b81134b32e03a42edd Mon Sep 17 00:00:00 2001 From: Mariano Date: Fri, 8 May 2026 15:28:50 +0100 Subject: [PATCH 02/50] feat(api): support onboardDate, offboardDate on member update and filtering Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/api/src/people/dto/update-people.dto.ts | 18 +++++++++++ apps/api/src/people/people.controller.ts | 14 +++++++++ apps/api/src/people/people.service.ts | 7 +++++ apps/api/src/people/utils/member-queries.ts | 33 +++++++++++++++++++- 4 files changed, 71 insertions(+), 1 deletion(-) 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.ts b/apps/api/src/people/people.controller.ts index e0ee6eee3b..cb62586973 100644 --- a/apps/api/src/people/people.controller.ts +++ b/apps/api/src/people/people.controller.ts @@ -99,10 +99,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 { diff --git a/apps/api/src/people/people.service.ts b/apps/api/src/people/people.service.ts index 09e712a772..91c02a34a5 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( 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 && From d82bb882c1d8efbd3e95fe06bb3153040be165b0 Mon Sep 17 00:00:00 2001 From: Mariano Date: Fri, 8 May 2026 15:29:09 +0100 Subject: [PATCH 03/50] feat(app): add employment evidence SWR hook and update PeopleResponseDto Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/app/src/hooks/use-employment-evidence.ts | 95 +++++++++++++++++++ apps/app/src/hooks/use-people-api.ts | 2 + 2 files changed, 97 insertions(+) create mode 100644 apps/app/src/hooks/use-employment-evidence.ts diff --git a/apps/app/src/hooks/use-employment-evidence.ts b/apps/app/src/hooks/use-employment-evidence.ts new file mode 100644 index 0000000000..e80c817cef --- /dev/null +++ b/apps/app/src/hooks/use-employment-evidence.ts @@ -0,0 +1,95 @@ +'use client'; + +import { useApi } from '@/hooks/use-api'; +import { useApiSWR } from '@/hooks/use-api-swr'; +import { useCallback } from 'react'; + +type EventType = 'onboard' | 'offboard'; + +interface AttachmentMetadata { + id: string; + name: string; + type: string; + downloadUrl: string; + createdAt: string; +} + +export function useEmploymentEvidence({ + memberId, + eventType, +}: { + memberId: string; + eventType: EventType; +}) { + const api = useApi(); + const endpoint = `/v1/people/${memberId}/employment-evidence/${eventType}`; + + const { data, error, isLoading, mutate } = useApiSWR(endpoint); + const attachments = data?.data ?? []; + + const uploadEvidence = useCallback( + (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = async () => { + try { + const base64String = reader.result as string; + const base64Data = base64String.split(',')[1]; + + const response = await api.post(endpoint, { + fileName: file.name, + fileType: file.type || 'application/octet-stream', + fileData: base64Data, + }); + + if (response.error) { + throw new Error(response.error); + } + + await mutate(); + resolve(response.data!); + } catch (error) { + reject(error); + } + }; + reader.onerror = () => reject(new Error('Failed to read file')); + reader.readAsDataURL(file); + }); + }, + [api, endpoint, mutate], + ); + + const deleteEvidence = useCallback( + async (attachmentId: string) => { + const response = await api.delete(`${endpoint}/${attachmentId}`); + if (response.error) { + throw new Error(response.error); + } + await mutate(); + return { success: true }; + }, + [api, endpoint, mutate], + ); + + const getDownloadUrl = useCallback( + async (attachmentId: string) => { + const response = await api.get<{ downloadUrl: string }>( + `/v1/attachments/${attachmentId}/download`, + ); + if (response.error) { + throw new Error(response.error); + } + return response.data!.downloadUrl; + }, + [api], + ); + + return { + attachments, + isLoading, + error, + uploadEvidence, + deleteEvidence, + getDownloadUrl, + }; +} diff --git a/apps/app/src/hooks/use-people-api.ts b/apps/app/src/hooks/use-people-api.ts index ba61a5b100..4a8d05d2bc 100644 --- a/apps/app/src/hooks/use-people-api.ts +++ b/apps/app/src/hooks/use-people-api.ts @@ -13,6 +13,8 @@ export interface PeopleResponseDto { isActive: boolean; deactivated: boolean; fleetDmLabelId: number | null; + onboardDate: string | null; + offboardDate: string | null; user: { id: string; name: string; From 7dbd47f8d5ce633fa5afb8c6e315df2dad4dbd11 Mon Sep 17 00:00:00 2001 From: Mariano Date: Fri, 8 May 2026 15:36:31 +0100 Subject: [PATCH 04/50] feat(app): add Employment Evidence tab to employee detail page Adds a new "Employment Evidence" tab to the employee detail page that allows uploading/downloading/deleting onboarding and offboarding evidence documents. The tab appears between Device and Background Check, and evidence sections are conditionally shown based on whether onboardDate or offboardDate is set on the member. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/EmployeeDetails.tsx | 102 +++++++++++++++++- 1 file changed, 100 insertions(+), 2 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeDetails.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeDetails.tsx index 9fd736404c..cb4f0fcf29 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeDetails.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeDetails.tsx @@ -53,6 +53,14 @@ export const EmployeeDetails = ({ 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(); @@ -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} + /> + + + From 17a5c00a03f4b04d6a1cd4e222c6eae38cef91c4 Mon Sep 17 00:00:00 2001 From: Mariano Date: Fri, 8 May 2026 15:37:04 +0100 Subject: [PATCH 05/50] feat(app): wire Employment Evidence tab into employee detail page Adds EmploymentEvidence and EmploymentEvidenceSection components and wires a new "Employment Evidence" tab into Employee.tsx between Device and Background Check. Evidence sections appear based on onboardDate and offboardDate values. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../[employeeId]/components/Employee.tsx | 19 ++- .../components/EmploymentEvidence.tsx | 56 +++++++ .../components/EmploymentEvidenceSection.tsx | 137 ++++++++++++++++++ 3 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmploymentEvidence.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmploymentEvidenceSection.tsx 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..a5c896b9b9 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 @@ -20,8 +20,16 @@ import { EmployeeDevice } from './EmployeeDevice'; import { EmployeePageHeader } from './EmployeePageHeader'; import { EmployeePolicies } from './EmployeePolicies'; import { EmployeeHipaaTraining, EmployeeTrainingVideos } from './EmployeeTraining'; +import { EmploymentEvidence } from './EmploymentEvidence'; -type EmployeeTab = 'details' | 'policies' | 'training' | 'hipaa' | 'device' | 'background-check'; +type EmployeeTab = + | 'details' + | 'policies' + | 'training' + | 'hipaa' + | 'device' + | 'employment-evidence' + | 'background-check'; interface EmployeeProps { employee: Member & { @@ -108,6 +116,7 @@ export function Employee({ Training Videos {hasHipaaFramework && HIPAA Training} Device + Employment Evidence {backgroundCheckStepEnabled && ( Background Check )} @@ -142,6 +151,14 @@ export function Employee({ fleetPolicies={fleetPolicies} /> + + + {backgroundCheckStepEnabled && ( + Set an onboard or offboard date in the Details tab to upload employment + evidence. + + ); + } + + return ( + + {onboardDate && ( + + )} + {offboardDate && ( + + )} + + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmploymentEvidenceSection.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmploymentEvidenceSection.tsx new file mode 100644 index 0000000000..88efa49e7a --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmploymentEvidenceSection.tsx @@ -0,0 +1,137 @@ +'use client'; + +import { useEmploymentEvidence } from '@/hooks/use-employment-evidence'; +import { Button, HStack, Section, Stack, Text } from '@trycompai/design-system'; +import { + DocumentDownload, + TrashCan, + Upload, +} from '@trycompai/design-system/icons'; +import { useRef, useState } from 'react'; +import { toast } from 'sonner'; + +interface EmploymentEvidenceSectionProps { + memberId: string; + eventType: 'onboard' | 'offboard'; + title: string; + description: string; + canEdit: boolean; +} + +export function EmploymentEvidenceSection({ + memberId, + eventType, + title, + description, + canEdit, +}: EmploymentEvidenceSectionProps) { + const { + attachments, + isLoading, + uploadEvidence, + deleteEvidence, + getDownloadUrl, + } = useEmploymentEvidence({ memberId, eventType }); + const fileInputRef = useRef(null); + const [isUploading, setIsUploading] = useState(false); + + const handleUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + setIsUploading(true); + try { + await uploadEvidence(file); + toast.success(`${title} uploaded`); + } catch { + toast.error('Failed to upload evidence'); + } finally { + setIsUploading(false); + if (fileInputRef.current) fileInputRef.current.value = ''; + } + }; + + const handleDelete = async (attachmentId: string, name: string) => { + try { + await deleteEvidence(attachmentId); + toast.success(`Deleted ${name}`); + } catch { + toast.error('Failed to delete evidence'); + } + }; + + const handleDownload = async (attachmentId: string) => { + try { + const url = await getDownloadUrl(attachmentId); + window.open(url, '_blank'); + } catch { + toast.error('Failed to get download URL'); + } + }; + + return ( +
+ {isLoading ? ( + Loading evidence... + ) : ( + + {attachments.length === 0 && ( + No evidence uploaded yet. + )} + {attachments.map((attachment) => ( + + {attachment.name} + +
+ +
+ {canEdit && ( +
+ +
+ )} +
+
+ ))} +
+ )} + + {canEdit && ( + <> + +
+ +
+ + )} +
+ ); +} From 0e5b3541c33bf036bfc20631b0340517b211ac0d Mon Sep 17 00:00:00 2001 From: Mariano Date: Fri, 8 May 2026 15:44:36 +0100 Subject: [PATCH 06/50] feat(api): add employment evidence CRUD endpoints on people controller Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/api/src/people/people.controller.ts | 73 ++++++++++++++++++++++++ apps/api/src/people/people.module.ts | 3 +- 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/apps/api/src/people/people.controller.ts b/apps/api/src/people/people.controller.ts index cb62586973..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' }) @@ -543,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], From aab6c9219e0c173dfb13db4323a37d06c4d5e9f4 Mon Sep 17 00:00:00 2001 From: Mariano Date: Fri, 8 May 2026 15:48:10 +0100 Subject: [PATCH 07/50] feat(app): add onboard/offboard date range filters to people table Co-Authored-By: Claude Opus 4.6 (1M context) --- .../all/components/TeamMembersClient.tsx | 82 ++++++++++++++++++- 1 file changed, 80 insertions(+), 2 deletions(-) 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..4a3c377c39 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 @@ -16,6 +16,7 @@ import { EmptyDescription, EmptyHeader, EmptyTitle, + Input, InputGroup, InputGroupAddon, InputGroupInput, @@ -81,6 +82,10 @@ export function TeamMembersClient({ const [searchQuery, setSearchQuery] = useState(''); const [roleFilter, setRoleFilter] = useState(''); const [statusFilter, setStatusFilter] = useState(''); + const [onboardAfter, setOnboardAfter] = useState(''); + const [onboardBefore, setOnboardBefore] = useState(''); + const [offboardAfter, setOffboardAfter] = useState(''); + const [offboardBefore, setOffboardBefore] = useState(''); const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(25); @@ -135,8 +140,29 @@ export function TeamMembersClient({ statusFilter, }); - 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 (onboardAfter || onboardBefore) { + if (!member.onboardDate) return false; + const onboard = new Date(member.onboardDate); + if (onboardAfter && onboard < new Date(onboardAfter)) return false; + if (onboardBefore && onboard > new Date(onboardBefore)) return false; + } + + if (offboardAfter || offboardBefore) { + if (!member.offboardDate) return false; + const offboard = new Date(member.offboardDate); + if (offboardAfter && offboard < new Date(offboardAfter)) return false; + if (offboardBefore && offboard > new Date(offboardBefore)) 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]; @@ -286,6 +312,58 @@ export function TeamMembersClient({ + {/* Onboard Date Filter */} +
+
+ { + setOnboardAfter(e.target.value); + setPage(1); + }} + /> +
+
+ { + setOnboardBefore(e.target.value); + setPage(1); + }} + /> +
+
+ + {/* Offboard Date Filter */} +
+
+ { + setOffboardAfter(e.target.value); + setPage(1); + }} + /> +
+
+ { + setOffboardBefore(e.target.value); + setPage(1); + }} + /> +
+
+ {hasAnyConnection && (
From 1ead44e7eb54b3087294850fca786357f070e200 Mon Sep 17 00:00:00 2001 From: Mariano Date: Fri, 8 May 2026 15:50:18 +0100 Subject: [PATCH 08/50] fix(api): add AttachmentsService mock to people controller tests Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/api/src/people/people.controller.spec.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) 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, ); }); From dd34b6dc34103d71fbbf33cdd594bfebe4809bfa Mon Sep 17 00:00:00 2001 From: Mariano Date: Sun, 10 May 2026 11:51:44 +0100 Subject: [PATCH 09/50] fix(app): add onboardDate/offboardDate to mock member factory Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/app/src/test-utils/mocks/auth.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/app/src/test-utils/mocks/auth.ts b/apps/app/src/test-utils/mocks/auth.ts index 135b192afc..f4edf9b820 100644 --- a/apps/app/src/test-utils/mocks/auth.ts +++ b/apps/app/src/test-utils/mocks/auth.ts @@ -83,6 +83,8 @@ export const createMockMember = (overrides?: Partial): Member => ({ externalUserId: null, externalUserSource: null, backgroundCheckExempt: false, + onboardDate: null, + offboardDate: null, ...overrides, }); From af1614c70a601689884cdecbd8689b94e3eeebe3 Mon Sep 17 00:00:00 2001 From: Mariano Date: Sun, 10 May 2026 11:59:54 +0100 Subject: [PATCH 10/50] fix(app): add labels to onboard/offboard date filters on people table Co-Authored-By: Claude Opus 4.6 (1M context) --- .../all/components/TeamMembersClient.tsx | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) 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 4a3c377c39..97364ed4e0 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 @@ -313,11 +313,11 @@ export function TeamMembersClient({
{/* Onboard Date Filter */} -
-
+
+ Onboarded +
{ setOnboardAfter(e.target.value); @@ -325,10 +325,10 @@ export function TeamMembersClient({ }} />
-
+ to +
{ setOnboardBefore(e.target.value); @@ -339,11 +339,11 @@ export function TeamMembersClient({
{/* Offboard Date Filter */} -
-
+
+ Offboarded +
{ setOffboardAfter(e.target.value); @@ -351,10 +351,10 @@ export function TeamMembersClient({ }} />
-
+ to +
{ setOffboardBefore(e.target.value); From 702fbb56706142ff9fab2a666ded4e5104c71add Mon Sep 17 00:00:00 2001 From: Mariano Date: Sun, 10 May 2026 12:09:12 +0100 Subject: [PATCH 11/50] feat(people): add onboarded/offboarded columns, remove date filters, auto-set offboardDate on deactivation Onboarded column falls back to member.createdAt when no explicit date is set. Offboarded column is blank for active members. Deactivating a member now auto-sets offboardDate if not already set. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/api/src/people/people.service.ts | 6 +- .../people/all/components/MemberRow.tsx | 19 +++++ .../all/components/PendingInvitationRow.tsx | 10 +++ .../all/components/TeamMembersClient.tsx | 84 +------------------ 4 files changed, 38 insertions(+), 81 deletions(-) diff --git a/apps/api/src/people/people.service.ts b/apps/api/src/people/people.service.ts index 91c02a34a5..3be40dd442 100644 --- a/apps/api/src/people/people.service.ts +++ b/apps/api/src/people/people.service.ts @@ -402,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/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 97364ed4e0..7c85ec49af 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 @@ -16,7 +16,6 @@ import { EmptyDescription, EmptyHeader, EmptyTitle, - Input, InputGroup, InputGroupAddon, InputGroupInput, @@ -82,10 +81,6 @@ export function TeamMembersClient({ const [searchQuery, setSearchQuery] = useState(''); const [roleFilter, setRoleFilter] = useState(''); const [statusFilter, setStatusFilter] = useState(''); - const [onboardAfter, setOnboardAfter] = useState(''); - const [onboardBefore, setOnboardBefore] = useState(''); - const [offboardAfter, setOffboardAfter] = useState(''); - const [offboardBefore, setOffboardBefore] = useState(''); const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(25); @@ -140,29 +135,8 @@ export function TeamMembersClient({ statusFilter, }); - const dateFilteredItems = filteredItems.filter((item) => { - if (item.type !== 'member') return true; - const member = item as MemberWithUser; - - if (onboardAfter || onboardBefore) { - if (!member.onboardDate) return false; - const onboard = new Date(member.onboardDate); - if (onboardAfter && onboard < new Date(onboardAfter)) return false; - if (onboardBefore && onboard > new Date(onboardBefore)) return false; - } - - if (offboardAfter || offboardBefore) { - if (!member.offboardDate) return false; - const offboard = new Date(member.offboardDate); - if (offboardAfter && offboard < new Date(offboardAfter)) return false; - if (offboardBefore && offboard > new Date(offboardBefore)) return false; - } - - return true; - }); - - const activeMembers = dateFilteredItems.filter((item) => item.type === 'member'); - const pendingInvites = dateFilteredItems.filter((item) => item.type === 'invitation'); + const activeMembers = filteredItems.filter((item) => item.type === 'member'); + const pendingInvites = filteredItems.filter((item) => item.type === 'invitation'); // Combine all items for table display const allDisplayItems = [...activeMembers, ...pendingInvites]; @@ -312,58 +286,6 @@ export function TeamMembersClient({
- {/* Onboard Date Filter */} -
- Onboarded -
- { - setOnboardAfter(e.target.value); - setPage(1); - }} - /> -
- to -
- { - setOnboardBefore(e.target.value); - setPage(1); - }} - /> -
-
- - {/* Offboard Date Filter */} -
- Offboarded -
- { - setOffboardAfter(e.target.value); - setPage(1); - }} - /> -
- to -
- { - setOffboardBefore(e.target.value); - setPage(1); - }} - /> -
-
- {hasAnyConnection && (
@@ -540,6 +462,8 @@ export function TeamMembersClient({
ROLE
+ ONBOARDED + OFFBOARDED TASKS ACTIONS From 2f0017db5f59be3887f78a9a7338550322a2b05d Mon Sep 17 00:00:00 2001 From: Mariano Date: Sun, 10 May 2026 12:10:30 +0100 Subject: [PATCH 12/50] feat(api): auto-set offboardDate when employee sync deactivates a member Co-Authored-By: Claude Opus 4.6 (1M context) --- .../services/generic-employee-sync.service.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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({ From c615f372c4121cb6dac3338aa6bfcb1c0bd9db7e Mon Sep 17 00:00:00 2001 From: Mariano Date: Sun, 10 May 2026 12:45:37 +0100 Subject: [PATCH 13/50] fix(app): show label instead of value in status filter dropdown Co-Authored-By: Claude Opus 4.6 (1M context) --- .../(app)/[orgId]/people/all/components/TeamMembersClient.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 7c85ec49af..07e65cda3e 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 From 78e5fbc25412baa5d1b663171b2291dd93ff2614 Mon Sep 17 00:00:00 2001 From: Mariano Date: Sun, 10 May 2026 12:47:49 +0100 Subject: [PATCH 14/50] fix(app): show label instead of value in role filter dropdown Co-Authored-By: Claude Opus 4.6 (1M context) --- .../(app)/[orgId]/people/all/components/TeamMembersClient.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 07e65cda3e..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 @@ -276,7 +276,9 @@ export function TeamMembersClient({ }} > - + + {{ owner: 'Owner', admin: 'Admin', auditor: 'Auditor', employee: 'Employee', contractor: 'Contractor' }[roleFilter] ?? 'All Roles'} + All Roles From 4a8df81f77d1f009f45761864bdca217224e5ea6 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Sun, 10 May 2026 13:11:53 +0100 Subject: [PATCH 15/50] fix(app): set min-width on calendar popover to prevent squished layout Co-Authored-By: Claude Opus 4.6 (1M context) --- .../people/[employeeId]/components/EmployeeDetails.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeDetails.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeDetails.tsx index cb4f0fcf29..eb5e43cd5a 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeDetails.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeDetails.tsx @@ -254,7 +254,7 @@ export const EmployeeDetails = ({ - + - + - + Date: Wed, 13 May 2026 10:23:07 -0400 Subject: [PATCH 16/50] chore(deps): bump @trycompai/design-system to 1.1.17 Fixes calendar squished layout, missing hover/selection states, and Button className override bug. Co-Authored-By: Claude Opus 4.6 (1M context) --- bun.lock | 16 ++++++++++++++-- package.json | 2 +- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index 8c3adca693..e3341359ae 100644 --- a/bun.lock +++ b/bun.lock @@ -7,7 +7,7 @@ "dependencies": { "@aws-sdk/client-s3": "3.1013.0", "@aws-sdk/s3-request-presigner": "3.1013.0", - "@trycompai/design-system": "1.1.4", + "@trycompai/design-system": "1.1.17", "@types/cheerio": "^1.0.0", "@types/react-syntax-highlighter": "^15.5.13", "@upstash/vector": "^1.2.3", @@ -2709,7 +2709,7 @@ "@trycompai/db": ["@trycompai/db@workspace:packages/db"], - "@trycompai/design-system": ["@trycompai/design-system@1.1.4", "", { "dependencies": { "@base-ui/react": "^1.0.0", "@carbon/icons-react": "^11.72.0", "@fontsource-variable/plus-jakarta-sans": "^5.2.8", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", "embla-carousel-react": "^8.6.0", "input-otp": "^1.4.2", "next-themes": "^0.4.6", "react-day-picker": "^9.13.0", "react-resizable-panels": "^4.2.0", "recharts": "2.15.4", "shadcn": "^3.6.2", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tw-animate-css": "^1.4.0", "vaul": "^1.1.2" }, "peerDependencies": { "react": "^19.0.0", "react-dom": "^19.0.0", "tailwindcss": "^4.0.0" } }, "sha512-WruX+BOp5dEL2rV2qEbEufl7AgpJmGoQFmBoqa9FJzDJqgboXoSvO8eQdk8K9kaTDY7UY2Xlg3WgXCnOIjrjwg=="], + "@trycompai/design-system": ["@trycompai/design-system@1.1.17", "", { "dependencies": { "@base-ui/react": "^1.0.0", "@carbon/icons-react": "^11.72.0", "@fontsource-variable/plus-jakarta-sans": "^5.2.8", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", "embla-carousel-react": "^8.6.0", "input-otp": "^1.4.2", "next-themes": "^0.4.6", "react-day-picker": "^9.13.0", "react-resizable-panels": "^4.2.0", "recharts": "2.15.4", "shadcn": "^3.6.2", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tw-animate-css": "^1.4.0", "vaul": "^1.1.2" }, "peerDependencies": { "react": "^19.0.0", "react-dom": "^19.0.0", "tailwindcss": "^4.0.0" } }, "sha512-4Ud7QE81FPl+NKOsx7TK4beof0jZXDvS08yA57RY8DhYmqmo9gr2TTQ5yPt73J0DikBAqQFPC3UFXehyn/GHtw=="], "@trycompai/device-agent": ["@trycompai/device-agent@workspace:packages/device-agent"], @@ -7317,6 +7317,8 @@ "@trycompai/integrations/ai": ["ai@6.0.175", "", { "dependencies": { "@ai-sdk/gateway": "3.0.110", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.26", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-6fFFHzbh6FIZnYc31V6osOxq25ABJYCShfG0O6ajHiA4FB/DgnPi1mP8cO5aAU3HNSbQHiMazdlh9bIsp97mVA=="], + "@trycompai/portal/@trycompai/design-system": ["@trycompai/design-system@1.1.4", "", { "dependencies": { "@base-ui/react": "^1.0.0", "@carbon/icons-react": "^11.72.0", "@fontsource-variable/plus-jakarta-sans": "^5.2.8", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", "embla-carousel-react": "^8.6.0", "input-otp": "^1.4.2", "next-themes": "^0.4.6", "react-day-picker": "^9.13.0", "react-resizable-panels": "^4.2.0", "recharts": "2.15.4", "shadcn": "^3.6.2", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tw-animate-css": "^1.4.0", "vaul": "^1.1.2" }, "peerDependencies": { "react": "^19.0.0", "react-dom": "^19.0.0", "tailwindcss": "^4.0.0" } }, "sha512-WruX+BOp5dEL2rV2qEbEufl7AgpJmGoQFmBoqa9FJzDJqgboXoSvO8eQdk8K9kaTDY7UY2Xlg3WgXCnOIjrjwg=="], + "@trycompai/portal/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@trycompai/ui/ai": ["ai@6.0.175", "", { "dependencies": { "@ai-sdk/gateway": "3.0.110", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.26", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-6fFFHzbh6FIZnYc31V6osOxq25ABJYCShfG0O6ajHiA4FB/DgnPi1mP8cO5aAU3HNSbQHiMazdlh9bIsp97mVA=="], @@ -8853,6 +8855,16 @@ "@trycompai/integrations/ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.26", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CsKNLKsOpvPujRlIYvoz+Ybw+kGn7J4/fIZa/58+R7iWLLfwn6ifE2G6Yq8K9XvH/I/3bzaDAJ3NhRwEMsLBKQ=="], + "@trycompai/portal/@trycompai/design-system/react-day-picker": ["react-day-picker@9.14.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "@tabby_ai/hijri-converter": "1.0.5", "date-fns": "^4.1.0", "date-fns-jalali": "4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA=="], + + "@trycompai/portal/@trycompai/design-system/react-resizable-panels": ["react-resizable-panels@4.11.0", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-LPk/AkFDGkg7SsbOyL93ojrE6E7lhrxxDwnYNjfmnSeI6BE7Sje6dB24PXgZk8DeugdeXNk1LO+ohRqIjhxiLw=="], + + "@trycompai/portal/@trycompai/design-system/recharts": ["recharts@2.15.4", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="], + + "@trycompai/portal/@trycompai/design-system/tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="], + + "@trycompai/portal/@trycompai/design-system/vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="], + "@trycompai/ui/ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.26", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CsKNLKsOpvPujRlIYvoz+Ybw+kGn7J4/fIZa/58+R7iWLLfwn6ifE2G6Yq8K9XvH/I/3bzaDAJ3NhRwEMsLBKQ=="], "@trycompai/ui/shiki/@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], diff --git a/package.json b/package.json index 796a19fd03..d3bc49f140 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,7 @@ "dependencies": { "@aws-sdk/client-s3": "3.1013.0", "@aws-sdk/s3-request-presigner": "3.1013.0", - "@trycompai/design-system": "1.1.4", + "@trycompai/design-system": "1.1.17", "@types/cheerio": "^1.0.0", "@types/react-syntax-highlighter": "^15.5.13", "@upstash/vector": "^1.2.3", From 6f35ba2dcf71afac3ad781473551cb4e74a2cb66 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Wed, 13 May 2026 10:23:19 -0400 Subject: [PATCH 17/50] fix(app): remove calendar min-width workaround (now handled by DS 1.1.17) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../people/[employeeId]/components/EmployeeDetails.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeDetails.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeDetails.tsx index eb5e43cd5a..cb4f0fcf29 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeDetails.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeDetails.tsx @@ -254,7 +254,7 @@ export const EmployeeDetails = ({ - + - + - + Date: Wed, 13 May 2026 10:30:49 -0400 Subject: [PATCH 18/50] feat(db): add offboarding checklist template and completion models Co-Authored-By: Claude Opus 4.6 (1M context) --- .../migration.sql | 61 +++++++++++++++++++ packages/db/prisma/schema/attachments.prisma | 1 + packages/db/prisma/schema/auth.prisma | 2 + .../schema/offboarding-checklist.prisma | 37 +++++++++++ packages/db/prisma/schema/organization.prisma | 4 ++ 5 files changed, 105 insertions(+) create mode 100644 packages/db/prisma/migrations/20260513143031_add_offboarding_checklist/migration.sql create mode 100644 packages/db/prisma/schema/offboarding-checklist.prisma diff --git a/packages/db/prisma/migrations/20260513143031_add_offboarding_checklist/migration.sql b/packages/db/prisma/migrations/20260513143031_add_offboarding_checklist/migration.sql new file mode 100644 index 0000000000..b9f8bef98c --- /dev/null +++ b/packages/db/prisma/migrations/20260513143031_add_offboarding_checklist/migration.sql @@ -0,0 +1,61 @@ +-- AlterEnum +ALTER TYPE "AttachmentEntityType" ADD VALUE 'offboarding_checklist'; + +-- CreateTable +CREATE TABLE "OffboardingChecklistTemplate" ( + "id" TEXT NOT NULL DEFAULT generate_prefixed_cuid('oct'::text), + "organizationId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "evidenceRequired" BOOLEAN NOT NULL DEFAULT false, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "isDefault" BOOLEAN NOT NULL DEFAULT false, + "isEnabled" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "OffboardingChecklistTemplate_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "OffboardingChecklistCompletion" ( + "id" TEXT NOT NULL DEFAULT generate_prefixed_cuid('occ'::text), + "organizationId" TEXT NOT NULL, + "memberId" TEXT NOT NULL, + "templateItemId" TEXT NOT NULL, + "completedById" TEXT NOT NULL, + "completedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "notes" TEXT, + + CONSTRAINT "OffboardingChecklistCompletion_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "OffboardingChecklistTemplate_organizationId_idx" ON "OffboardingChecklistTemplate"("organizationId"); + +-- CreateIndex +CREATE INDEX "OffboardingChecklistTemplate_organizationId_isEnabled_idx" ON "OffboardingChecklistTemplate"("organizationId", "isEnabled"); + +-- CreateIndex +CREATE INDEX "OffboardingChecklistCompletion_organizationId_idx" ON "OffboardingChecklistCompletion"("organizationId"); + +-- CreateIndex +CREATE INDEX "OffboardingChecklistCompletion_memberId_idx" ON "OffboardingChecklistCompletion"("memberId"); + +-- CreateIndex +CREATE UNIQUE INDEX "OffboardingChecklistCompletion_memberId_templateItemId_key" ON "OffboardingChecklistCompletion"("memberId", "templateItemId"); + +-- AddForeignKey +ALTER TABLE "OffboardingChecklistTemplate" ADD CONSTRAINT "OffboardingChecklistTemplate_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OffboardingChecklistCompletion" ADD CONSTRAINT "OffboardingChecklistCompletion_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OffboardingChecklistCompletion" ADD CONSTRAINT "OffboardingChecklistCompletion_memberId_fkey" FOREIGN KEY ("memberId") REFERENCES "Member"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OffboardingChecklistCompletion" ADD CONSTRAINT "OffboardingChecklistCompletion_templateItemId_fkey" FOREIGN KEY ("templateItemId") REFERENCES "OffboardingChecklistTemplate"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "OffboardingChecklistCompletion" ADD CONSTRAINT "OffboardingChecklistCompletion_completedById_fkey" FOREIGN KEY ("completedById") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema/attachments.prisma b/packages/db/prisma/schema/attachments.prisma index e7789e7dd0..4d83eddb5e 100644 --- a/packages/db/prisma/schema/attachments.prisma +++ b/packages/db/prisma/schema/attachments.prisma @@ -29,6 +29,7 @@ enum AttachmentEntityType { background_check employment_onboard employment_offboard + offboarding_checklist } enum AttachmentType { diff --git a/packages/db/prisma/schema/auth.prisma b/packages/db/prisma/schema/auth.prisma index cf865ac4ba..0792ab3d05 100644 --- a/packages/db/prisma/schema/auth.prisma +++ b/packages/db/prisma/schema/auth.prisma @@ -29,6 +29,7 @@ model User { lockedTimelineInstances TimelineInstance[] @relation("TimelineInstanceLockedBy") unlockedTimelineInstances TimelineInstance[] @relation("TimelineInstanceUnlockedBy") publishedFrameworkVersions FrameworkVersion[] @relation("FrameworkVersionPublisher") + offboardingChecklistCompletions OffboardingChecklistCompletion[] @relation("OffboardingChecklistCompletedBy") @@unique([email]) } @@ -147,6 +148,7 @@ model Member { approvedTasks Task[] @relation("TaskApprover") devices Device[] backgroundCheckRequests BackgroundCheckRequest[] + offboardingChecklistCompletions OffboardingChecklistCompletion[] } model Invitation { diff --git a/packages/db/prisma/schema/offboarding-checklist.prisma b/packages/db/prisma/schema/offboarding-checklist.prisma new file mode 100644 index 0000000000..51e0cc1a51 --- /dev/null +++ b/packages/db/prisma/schema/offboarding-checklist.prisma @@ -0,0 +1,37 @@ +model OffboardingChecklistTemplate { + id String @id @default(dbgenerated("generate_prefixed_cuid('oct'::text)")) + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + title String + description String? + evidenceRequired Boolean @default(false) + sortOrder Int @default(0) + isDefault Boolean @default(false) + isEnabled Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + completions OffboardingChecklistCompletion[] + + @@index([organizationId]) + @@index([organizationId, isEnabled]) +} + +model OffboardingChecklistCompletion { + id String @id @default(dbgenerated("generate_prefixed_cuid('occ'::text)")) + organizationId String + memberId String + templateItemId String + completedById String + completedAt DateTime @default(now()) + notes String? + + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + member Member @relation(fields: [memberId], references: [id], onDelete: Cascade) + templateItem OffboardingChecklistTemplate @relation(fields: [templateItemId], references: [id], onDelete: Cascade) + completedBy User @relation("OffboardingChecklistCompletedBy", fields: [completedById], references: [id], onDelete: Cascade) + + @@unique([memberId, templateItemId]) + @@index([organizationId]) + @@index([memberId]) +} diff --git a/packages/db/prisma/schema/organization.prisma b/packages/db/prisma/schema/organization.prisma index 04e9bc24cd..a2440773ce 100644 --- a/packages/db/prisma/schema/organization.prisma +++ b/packages/db/prisma/schema/organization.prisma @@ -100,5 +100,9 @@ model Organization { customFrameworks CustomFramework[] customRequirements CustomRequirement[] + // Offboarding Checklist + offboardingChecklistTemplates OffboardingChecklistTemplate[] + offboardingChecklistCompletions OffboardingChecklistCompletion[] + @@index([slug]) } From 7bfc796d5011986dc359c9b2c2c5284044dd166c Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Wed, 13 May 2026 10:33:25 -0400 Subject: [PATCH 19/50] feat(api): add offboarding checklist service with default items and completion tracking Co-Authored-By: Claude Opus 4.6 (1M context) --- .../default-checklist-items.ts | 58 +++ .../offboarding-checklist.service.spec.ts | 407 ++++++++++++++++++ .../offboarding-checklist.service.ts | 297 +++++++++++++ 3 files changed, 762 insertions(+) create mode 100644 apps/api/src/offboarding-checklist/default-checklist-items.ts create mode 100644 apps/api/src/offboarding-checklist/offboarding-checklist.service.spec.ts create mode 100644 apps/api/src/offboarding-checklist/offboarding-checklist.service.ts 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..2043cccea3 --- /dev/null +++ b/apps/api/src/offboarding-checklist/default-checklist-items.ts @@ -0,0 +1,58 @@ +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, + sortOrder: 1, + }, + { + title: 'Remove from identity provider', + description: + 'Remove the employee from your identity provider (e.g., Okta, Azure AD, Google Workspace).', + evidenceRequired: true, + sortOrder: 2, + }, + { + title: 'Retrieve company devices', + description: + 'Collect all company-owned hardware including laptops, phones, access badges, and security keys.', + evidenceRequired: true, + 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, + 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, + sortOrder: 5, + }, + { + title: 'Notify relevant teams', + description: + "Inform the employee's team, IT, HR, and any relevant stakeholders of the departure.", + evidenceRequired: false, + sortOrder: 6, + }, + { + title: 'Exit interview completed', + description: + 'Conduct an exit interview covering security reminders and NDA obligations.', + evidenceRequired: 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, + sortOrder: 8, + }, +] as const; 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..936bf086a4 --- /dev/null +++ b/apps/api/src/offboarding-checklist/offboarding-checklist.service.spec.ts @@ -0,0 +1,407 @@ +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(), + }, +}; + +jest.mock('@db', () => ({ + db: mockDb, + AttachmentEntityType: { + offboarding_checklist: 'offboarding_checklist', + }, +})); + +import { OffboardingChecklistService } from './offboarding-checklist.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; + + beforeEach(() => { + jest.clearAllMocks(); + service = new OffboardingChecklistService( + mockAttachmentsService as never, + ); + }); + + 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); + }); + }); +}); 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..6d1333a2b6 --- /dev/null +++ b/apps/api/src/offboarding-checklist/offboarding-checklist.service.ts @@ -0,0 +1,297 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { AttachmentEntityType, db } from '@db'; +import { AttachmentsService } from '../attachments/attachments.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) {} + + 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, + ); + } + + 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, + sortOrder: item.sortOrder, + isDefault: true, + isEnabled: true, + })), + }); + } +} From 4e4087cc4d53a3d9f02be868d43377d3114cce8e Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Wed, 13 May 2026 10:35:43 -0400 Subject: [PATCH 20/50] feat(api): add offboarding checklist controller, DTOs, and module Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/api/src/app.module.ts | 2 + .../dto/complete-checklist-item.dto.ts | 30 ++++ .../dto/create-template-item.dto.ts | 25 ++++ .../dto/update-template-item.dto.ts | 29 ++++ .../offboarding-checklist.controller.ts | 137 ++++++++++++++++++ .../offboarding-checklist.module.ts | 13 ++ 6 files changed, 236 insertions(+) create mode 100644 apps/api/src/offboarding-checklist/dto/complete-checklist-item.dto.ts create mode 100644 apps/api/src/offboarding-checklist/dto/create-template-item.dto.ts create mode 100644 apps/api/src/offboarding-checklist/dto/update-template-item.dto.ts create mode 100644 apps/api/src/offboarding-checklist/offboarding-checklist.controller.ts create mode 100644 apps/api/src/offboarding-checklist/offboarding-checklist.module.ts 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/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..0e87f002d0 --- /dev/null +++ b/apps/api/src/offboarding-checklist/offboarding-checklist.controller.ts @@ -0,0 +1,137 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + UseGuards, +} from '@nestjs/common'; +import { 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!, + }); + } +} 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..4cf8b76ab3 --- /dev/null +++ b/apps/api/src/offboarding-checklist/offboarding-checklist.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { AttachmentsModule } from '../attachments/attachments.module'; +import { OffboardingChecklistController } from './offboarding-checklist.controller'; +import { OffboardingChecklistService } from './offboarding-checklist.service'; + +@Module({ + imports: [AuthModule, AttachmentsModule], + controllers: [OffboardingChecklistController], + providers: [OffboardingChecklistService], + exports: [OffboardingChecklistService], +}) +export class OffboardingChecklistModule {} From 4479e2d19900c487f6681cecdec777c7c2e7b2b7 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Wed, 13 May 2026 10:37:11 -0400 Subject: [PATCH 21/50] feat(app): add useOffboardingChecklist SWR hook Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/hooks/use-offboarding-checklist.ts | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 apps/app/src/hooks/use-offboarding-checklist.ts diff --git a/apps/app/src/hooks/use-offboarding-checklist.ts b/apps/app/src/hooks/use-offboarding-checklist.ts new file mode 100644 index 0000000000..de641cca89 --- /dev/null +++ b/apps/app/src/hooks/use-offboarding-checklist.ts @@ -0,0 +1,141 @@ +'use client'; + +import { useApi } from '@/hooks/use-api'; +import { useApiSWR } from '@/hooks/use-api-swr'; +import { useCallback } from 'react'; + +interface CompletedBy { + id: string; + name: string; + email: string; +} + +interface EvidenceAttachment { + id: string; + name: string; + type: string; + downloadUrl: string; + createdAt: string; +} + +export interface ChecklistItem { + templateItemId: string; + title: string; + description: string | null; + evidenceRequired: boolean; + sortOrder: number; + completed: boolean; + completedAt: string | null; + completedBy: CompletedBy | null; + completionId: string | null; + notes: string | null; + evidence: EvidenceAttachment[]; +} + +interface MemberChecklistResponse { + items: ChecklistItem[]; + totalItems: number; + completedItems: number; +} + +export function useOffboardingChecklist(memberId: string) { + const api = useApi(); + const endpoint = `/v1/offboarding-checklist/member/${memberId}`; + + const { data, error, isLoading, mutate } = useApiSWR(endpoint); + const checklist = data?.data ?? null; + + const completeItem = useCallback( + async ({ + templateItemId, + notes, + file, + }: { + templateItemId: string; + notes?: string; + file?: File; + }) => { + let body: Record = {}; + if (notes) body.notes = notes; + + if (file) { + const base64 = await fileToBase64(file); + body = { + ...body, + fileName: file.name, + fileType: file.type || 'application/octet-stream', + fileData: base64, + }; + } + + const response = await api.post( + `/v1/offboarding-checklist/member/${memberId}/item/${templateItemId}/complete`, + body, + ); + if (response.error) throw new Error(response.error); + await mutate(); + }, + [api, memberId, mutate], + ); + + const uncompleteItem = useCallback( + async (templateItemId: string) => { + const response = await api.delete( + `/v1/offboarding-checklist/member/${memberId}/item/${templateItemId}/complete`, + ); + if (response.error) throw new Error(response.error); + await mutate(); + }, + [api, memberId, mutate], + ); + + const uploadEvidence = useCallback( + async (templateItemId: string, file: File) => { + const base64 = await fileToBase64(file); + const response = await api.post( + `/v1/offboarding-checklist/member/${memberId}/item/${templateItemId}/evidence`, + { + fileName: file.name, + fileType: file.type || 'application/octet-stream', + fileData: base64, + }, + ); + if (response.error) throw new Error(response.error); + await mutate(); + }, + [api, memberId, mutate], + ); + + const getDownloadUrl = useCallback( + async (attachmentId: string) => { + const response = await api.get<{ downloadUrl: string }>( + `/v1/attachments/${attachmentId}/download`, + ); + if (response.error) throw new Error(response.error); + return response.data!.downloadUrl; + }, + [api], + ); + + return { + checklist, + isLoading, + error, + completeItem, + uncompleteItem, + uploadEvidence, + getDownloadUrl, + }; +} + +function fileToBase64(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + resolve(result.split(',')[1]); + }; + reader.onerror = () => reject(new Error('Failed to read file')); + reader.readAsDataURL(file); + }); +} From 82884ad9b35e7337dae0fecffac2f9ab11a97096 Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Wed, 13 May 2026 10:39:51 -0400 Subject: [PATCH 22/50] feat(app): add offboarding checklist settings to People settings page Co-Authored-By: Claude Opus 4.6 (1M context) --- .../OffboardingChecklistSettings.tsx | 363 ++++++++++++++++++ .../settings/components/PeopleSettings.tsx | 2 + 2 files changed, 365 insertions(+) create mode 100644 apps/app/src/app/(app)/[orgId]/people/settings/components/OffboardingChecklistSettings.tsx 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 + /> +
+
+ +