Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
97d06f4
feat(db): add onboardDate, offboardDate fields and employment attachm…
Marfuen May 8, 2026
c163049
feat(api): support onboardDate, offboardDate on member update and fil…
Marfuen May 8, 2026
d82bb88
feat(app): add employment evidence SWR hook and update PeopleResponseDto
Marfuen May 8, 2026
7dbd47f
feat(app): add Employment Evidence tab to employee detail page
Marfuen May 8, 2026
17a5c00
feat(app): wire Employment Evidence tab into employee detail page
Marfuen May 8, 2026
0e5b354
feat(api): add employment evidence CRUD endpoints on people controller
Marfuen May 8, 2026
aab6c92
feat(app): add onboard/offboard date range filters to people table
Marfuen May 8, 2026
1ead44e
fix(api): add AttachmentsService mock to people controller tests
Marfuen May 8, 2026
dd34b6d
fix(app): add onboardDate/offboardDate to mock member factory
Marfuen May 10, 2026
af1614c
fix(app): add labels to onboard/offboard date filters on people table
Marfuen May 10, 2026
702fbb5
feat(people): add onboarded/offboarded columns, remove date filters, …
Marfuen May 10, 2026
2f0017d
feat(api): auto-set offboardDate when employee sync deactivates a member
Marfuen May 10, 2026
c615f37
fix(app): show label instead of value in status filter dropdown
Marfuen May 10, 2026
78e5fbc
fix(app): show label instead of value in role filter dropdown
Marfuen May 10, 2026
4a8df81
fix(app): set min-width on calendar popover to prevent squished layout
Marfuen May 10, 2026
04121c4
chore(deps): bump @trycompai/design-system to 1.1.17
Marfuen May 13, 2026
6f35ba2
fix(app): remove calendar min-width workaround (now handled by DS 1.1…
Marfuen May 13, 2026
52f0920
feat(db): add offboarding checklist template and completion models
Marfuen May 13, 2026
7bfc796
feat(api): add offboarding checklist service with default items and c…
Marfuen May 13, 2026
4e4087c
feat(api): add offboarding checklist controller, DTOs, and module
Marfuen May 13, 2026
4479e2d
feat(app): add useOffboardingChecklist SWR hook
Marfuen May 13, 2026
82884ad
feat(app): add offboarding checklist settings to People settings page
Marfuen May 13, 2026
c72a1ec
feat(app): replace Employment Evidence tab with Offboarding checklist
Marfuen May 13, 2026
6c9c990
chore(docs): regenerate openapi.json with offboarding checklist endpo…
Marfuen May 13, 2026
c9d66e8
feat(app): use accordion with dropzone for offboarding checklist items
Marfuen May 13, 2026
9fd9ce8
fix(app): make entire checklist item row clickable to expand
Marfuen May 13, 2026
51802e2
fix(app): add hover state to checklist item rows for better affordance
Marfuen May 13, 2026
2c73df9
fix(app): ensure checklist items stretch to full container width
Marfuen May 13, 2026
cdc3285
fix(app): move border onto Collapsible root so expanded content stays…
Marfuen May 13, 2026
28211f0
refactor(app): use DS Accordion for offboarding checklist items
Marfuen May 13, 2026
1946f43
refactor(app): replace checkbox with status badges and action buttons
Marfuen May 13, 2026
a5bd09e
feat(app): make employee detail tabs bookmarkable via ?tab= query param
Marfuen May 13, 2026
22362d5
fix(app): use colored badges for checklist item status
Marfuen May 13, 2026
dcc5d48
refactor(app): render non-evidence checklist items as flat rows with …
Marfuen May 13, 2026
c0286fd
chore(deps): bump @trycompai/design-system to 1.1.18
Marfuen May 13, 2026
1392114
fix(app): remove redundant wrapper div causing double gap between che…
Marfuen May 13, 2026
1b04b55
chore(deps): bump @trycompai/design-system to 1.1.19
Marfuen May 13, 2026
31637dc
feat(db): add access revocation model and isAccessRevocation flag
Marfuen May 13, 2026
2a0f4c2
feat(api): add per-vendor access revocation tracking with auto-comple…
Marfuen May 13, 2026
ffb665d
feat(app): add per-vendor access revocation list to offboarding check…
Marfuen May 13, 2026
5ab7c50
fix(app): use multiple prop instead of type="multiple" on Accordion
Marfuen May 13, 2026
8bedf11
fix(app): clarify access revocation UI is acknowledgment tracking, no…
Marfuen May 13, 2026
7255e68
fix(app): align vendor name, badge, and audit info on one line
Marfuen May 13, 2026
55073fa
fix(app): fix vendor row alignment using baseline alignment and plain…
Marfuen May 13, 2026
de04eb1
fix(app): show confirmation audit info below vendor name
Marfuen May 13, 2026
513d850
feat(people): add confirm-all button and pagination to vendor access …
Marfuen May 13, 2026
82a8205
fix(app): add min-height to vendor list to prevent layout shift betwe…
Marfuen May 13, 2026
a2cf653
fix(app): use fixed height for vendor list to prevent layout shift be…
Marfuen May 13, 2026
570c0cb
feat(app): redesign offboarding checklist UI with summary card, compa…
Marfuen May 13, 2026
15c66c3
fix(app): pixel-perfect offboarding UI matching design mockup
Marfuen May 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -122,6 +123,7 @@ import { BillingModule } from './billing/billing.module';
AdminOrganizationsModule,
AdminFeatureFlagsModule,
TimelinesModule,
OffboardingChecklistModule,
],
controllers: [AppController],
providers: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
207 changes: 207 additions & 0 deletions apps/api/src/offboarding-checklist/access-revocation.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { db } from '@db';

@Injectable()
export class AccessRevocationService {
async getAccessRevocations(organizationId: string, memberId: string) {
const vendors = await db.vendor.findMany({
where: { organizationId },
select: { id: true, name: true },
orderBy: { name: 'asc' },
});

const revocations = await db.offboardingAccessRevocation.findMany({
where: { organizationId, memberId },
include: {
revokedBy: { select: { id: true, name: true, email: true } },
},
});

const revocationMap = new Map(
revocations.map((r) => [r.vendorId, r]),
);

const vendorList = vendors.map((vendor) => {
const revocation = revocationMap.get(vendor.id);
return {
vendorId: vendor.id,
vendorName: vendor.name,
revoked: !!revocation,
revokedAt: revocation?.revokedAt ?? null,
revokedBy: revocation?.revokedBy ?? null,
notes: revocation?.notes ?? null,
};
});

return {
vendors: vendorList,
totalVendors: vendors.length,
revokedCount: revocations.length,
};
}

async revokeVendorAccess({
organizationId,
memberId,
vendorId,
revokedById,
notes,
}: {
organizationId: string;
memberId: string;
vendorId: string;
revokedById: string;
notes?: string;
}) {
const vendor = await db.vendor.findFirst({
where: { id: vendorId, organizationId },
});

if (!vendor) {
throw new NotFoundException('Vendor not found in this organization');
}

const existing = await db.offboardingAccessRevocation.findUnique({
where: { memberId_vendorId: { memberId, vendorId } },
});

if (existing) {
throw new BadRequestException(
'Vendor access has already been revoked for this member',
);
}

const revocation = await db.offboardingAccessRevocation.create({
data: {
organizationId,
memberId,
vendorId,
revokedById,
notes,
},
include: {
revokedBy: { select: { id: true, name: true, email: true } },
},
});

await this.syncAccessRevocationCompletion(
organizationId,
memberId,
revokedById,
);

return revocation;
}

async undoVendorRevocation({
organizationId,
memberId,
vendorId,
}: {
organizationId: string;
memberId: string;
vendorId: string;
}) {
const revocation = await db.offboardingAccessRevocation.findUnique({
where: { memberId_vendorId: { memberId, vendorId } },
});

if (!revocation) {
throw new NotFoundException('Revocation record not found');
}

await db.offboardingAccessRevocation.delete({
where: { id: revocation.id },
});

await this.syncAccessRevocationCompletion(organizationId, memberId);

return { success: true };
}

async revokeAllVendorAccess({
organizationId,
memberId,
revokedById,
}: {
organizationId: string;
memberId: string;
revokedById: string;
}) {
const vendors = await db.vendor.findMany({
where: { organizationId },
select: { id: true },
});

const existing = await db.offboardingAccessRevocation.findMany({
where: { organizationId, memberId },
select: { vendorId: true },
});

const existingSet = new Set(existing.map((r) => r.vendorId));
const toCreate = vendors.filter((v) => !existingSet.has(v.id));

if (toCreate.length > 0) {
await db.offboardingAccessRevocation.createMany({
data: toCreate.map((v) => ({
organizationId,
memberId,
vendorId: v.id,
revokedById,
})),
skipDuplicates: true,
});
}

await this.syncAccessRevocationCompletion(organizationId, memberId, revokedById);

return { confirmed: toCreate.length };
}

private async syncAccessRevocationCompletion(
organizationId: string,
memberId: string,
completedById?: string,
) {
const templateItem = await db.offboardingChecklistTemplate.findFirst({
where: { organizationId, isAccessRevocation: true, isEnabled: true },
});

if (!templateItem) {
return;
}

const { totalVendors, revokedCount } = await this.getAccessRevocations(
organizationId,
memberId,
);

const allRevoked = totalVendors > 0 && revokedCount === totalVendors;

const existingCompletion =
await db.offboardingChecklistCompletion.findFirst({
where: { organizationId, memberId, templateItemId: templateItem.id },
});

if (allRevoked && !existingCompletion && completedById) {
await db.offboardingChecklistCompletion.create({
data: {
organizationId,
memberId,
templateItemId: templateItem.id,
completedById,
},
});
}

if (!allRevoked && existingCompletion) {
await db.offboardingChecklistCompletion.delete({
where: { id: existingCompletion.id },
});
}
}
}
66 changes: 66 additions & 0 deletions apps/api/src/offboarding-checklist/default-checklist-items.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
export const DEFAULT_OFFBOARDING_CHECKLIST_ITEMS = [
{
title: 'Revoke system access',
description:
"Disable or remove the employee's access to all company systems, applications, and cloud services.",
evidenceRequired: true,
isAccessRevocation: true,
sortOrder: 1,
},
{
title: 'Remove from identity provider',
description:
'Remove the employee from your identity provider (e.g., Okta, Azure AD, Google Workspace).',
evidenceRequired: true,
isAccessRevocation: false,
sortOrder: 2,
},
{
title: 'Retrieve company devices',
description:
'Collect all company-owned hardware including laptops, phones, access badges, and security keys.',
evidenceRequired: true,
isAccessRevocation: false,
sortOrder: 3,
},
{
title: 'Deactivate email and accounts',
description:
"Deactivate or redirect the employee's email account and remove from shared mailboxes and distribution lists.",
evidenceRequired: true,
isAccessRevocation: false,
sortOrder: 4,
},
{
title: 'Revoke privileged access',
description:
'Remove any elevated permissions, admin rights, SSH keys, API tokens, or shared credentials the employee had access to.',
evidenceRequired: true,
isAccessRevocation: false,
sortOrder: 5,
},
{
title: 'Notify relevant teams',
description:
"Inform the employee's team, IT, HR, and any relevant stakeholders of the departure.",
evidenceRequired: false,
isAccessRevocation: false,
sortOrder: 6,
},
{
title: 'Exit interview completed',
description:
'Conduct an exit interview covering security reminders and NDA obligations.',
evidenceRequired: false,
isAccessRevocation: false,
sortOrder: 7,
},
{
title: 'Update org chart and documentation',
description:
'Remove the employee from the org chart, on-call rotations, and internal documentation.',
evidenceRequired: false,
isAccessRevocation: false,
sortOrder: 8,
},
] as const;
Original file line number Diff line number Diff line change
@@ -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;
}
25 changes: 25 additions & 0 deletions apps/api/src/offboarding-checklist/dto/create-template-item.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
29 changes: 29 additions & 0 deletions apps/api/src/offboarding-checklist/dto/update-template-item.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading