Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
95 commits
Select commit Hold shift + click to select a range
5ea8d9f
feat(db): add onboardDate, offboardDate fields and employment attachm…
Marfuen May 8, 2026
25429c6
feat(api): support onboardDate, offboardDate on member update and fil…
Marfuen May 8, 2026
cbb2e3d
feat(app): add employment evidence SWR hook and update PeopleResponseDto
Marfuen May 8, 2026
cf836bf
feat(app): add Employment Evidence tab to employee detail page
Marfuen May 8, 2026
141af5c
feat(app): wire Employment Evidence tab into employee detail page
Marfuen May 8, 2026
8d9d8c1
feat(api): add employment evidence CRUD endpoints on people controller
Marfuen May 8, 2026
7a4deec
feat(app): add onboard/offboard date range filters to people table
Marfuen May 8, 2026
a6bc6fa
fix(api): add AttachmentsService mock to people controller tests
Marfuen May 8, 2026
2d22d4f
fix(app): add onboardDate/offboardDate to mock member factory
Marfuen May 10, 2026
4b031b5
fix(app): add labels to onboard/offboard date filters on people table
Marfuen May 10, 2026
e793be0
feat(people): add onboarded/offboarded columns, remove date filters, …
Marfuen May 10, 2026
a977727
feat(api): auto-set offboardDate when employee sync deactivates a member
Marfuen May 10, 2026
6d63f25
fix(app): show label instead of value in status filter dropdown
Marfuen May 10, 2026
28e20bb
fix(app): show label instead of value in role filter dropdown
Marfuen May 10, 2026
0256153
fix(app): set min-width on calendar popover to prevent squished layout
Marfuen May 10, 2026
4fcbaba
chore(deps): bump @trycompai/design-system to 1.1.17
Marfuen May 13, 2026
3729fb1
fix(app): remove calendar min-width workaround (now handled by DS 1.1…
Marfuen May 13, 2026
47d814c
feat(db): add offboarding checklist template and completion models
Marfuen May 13, 2026
4856b57
feat(api): add offboarding checklist service with default items and c…
Marfuen May 13, 2026
9d54433
feat(api): add offboarding checklist controller, DTOs, and module
Marfuen May 13, 2026
7979bd6
feat(app): add useOffboardingChecklist SWR hook
Marfuen May 13, 2026
1867f52
feat(app): add offboarding checklist settings to People settings page
Marfuen May 13, 2026
6c22188
feat(app): replace Employment Evidence tab with Offboarding checklist
Marfuen May 13, 2026
0f87f73
chore(docs): regenerate openapi.json with offboarding checklist endpo…
Marfuen May 13, 2026
d5f5d81
feat(app): use accordion with dropzone for offboarding checklist items
Marfuen May 13, 2026
8a06bf4
fix(app): make entire checklist item row clickable to expand
Marfuen May 13, 2026
f99a1fb
fix(app): add hover state to checklist item rows for better affordance
Marfuen May 13, 2026
a9f4ed2
fix(app): ensure checklist items stretch to full container width
Marfuen May 13, 2026
5eedc39
fix(app): move border onto Collapsible root so expanded content stays…
Marfuen May 13, 2026
7c33f6c
refactor(app): use DS Accordion for offboarding checklist items
Marfuen May 13, 2026
c40eba7
refactor(app): replace checkbox with status badges and action buttons
Marfuen May 13, 2026
0e4bdfb
feat(app): make employee detail tabs bookmarkable via ?tab= query param
Marfuen May 13, 2026
7c103f4
fix(app): use colored badges for checklist item status
Marfuen May 13, 2026
97fd85b
refactor(app): render non-evidence checklist items as flat rows with …
Marfuen May 13, 2026
518c165
chore(deps): bump @trycompai/design-system to 1.1.18
Marfuen May 13, 2026
1702da4
fix(app): remove redundant wrapper div causing double gap between che…
Marfuen May 13, 2026
c0ad577
chore(deps): bump @trycompai/design-system to 1.1.19
Marfuen May 13, 2026
d7fa4c9
feat(db): add access revocation model and isAccessRevocation flag
Marfuen May 13, 2026
4b09a53
feat(api): add per-vendor access revocation tracking with auto-comple…
Marfuen May 13, 2026
6083ccf
feat(app): add per-vendor access revocation list to offboarding check…
Marfuen May 13, 2026
112d897
fix(app): use multiple prop instead of type="multiple" on Accordion
Marfuen May 13, 2026
4dd6591
fix(app): clarify access revocation UI is acknowledgment tracking, no…
Marfuen May 13, 2026
f6e3a8a
fix(app): align vendor name, badge, and audit info on one line
Marfuen May 13, 2026
8dcf351
fix(app): fix vendor row alignment using baseline alignment and plain…
Marfuen May 13, 2026
5107b74
fix(app): show confirmation audit info below vendor name
Marfuen May 13, 2026
9959645
feat(people): add confirm-all button and pagination to vendor access …
Marfuen May 13, 2026
b5675b6
fix(app): add min-height to vendor list to prevent layout shift betwe…
Marfuen May 13, 2026
67617a0
fix(app): use fixed height for vendor list to prevent layout shift be…
Marfuen May 13, 2026
fb2fa90
feat(app): redesign offboarding checklist UI with summary card, compa…
Marfuen May 13, 2026
ef687f9
fix(app): pixel-perfect offboarding UI matching design mockup
Marfuen May 13, 2026
e089738
feat(api): add pending offboardings endpoint for overview todos
Marfuen May 18, 2026
10efbef
feat(app): add TodosOverview component for pending offboardings
Marfuen May 18, 2026
89efbb9
feat(app): add Todos card to overview page grid
Marfuen May 18, 2026
7df1ba6
fix(app): make todo items clearly state the action needed
Marfuen May 18, 2026
5c08e4b
fix(app): match Quick Actions row style for todos — icon + title + ar…
Marfuen May 18, 2026
1b0f074
fix(app): simplify todo rows to just title text links
Marfuen May 18, 2026
bc9b79a
fix(app): match Quick Actions row style — title, subtitle, arrow button
Marfuen May 18, 2026
ecc8913
fix(app): use proper DS Button for attach evidence on vendor rows
Marfuen May 18, 2026
450e0d5
fix(app): use sm button size instead of xs for vendor row actions
Marfuen May 18, 2026
5916733
fix(app): fix completeItem using undefined templateItemId + show vend…
Marfuen May 18, 2026
63c2970
fix(api): move confirm-all route before :vendorId to prevent route co…
Marfuen May 18, 2026
2ac0b86
fix(app): use calendar icon instead of chevron on date picker triggers
Marfuen May 18, 2026
da48aba
fix(app): default onboard date to join date (createdAt) when not set
Marfuen May 18, 2026
72c66c9
refactor(app): remove redundant Join Date field, keep only Onboard Date
Marfuen May 18, 2026
91b8619
feat(app): add onboarded/offboarded date range filters to people table
Marfuen May 18, 2026
88a9f71
fix(app): show onboarded and offboarded date filters directly in filt…
Marfuen May 18, 2026
45b50e5
fix(app): use DS Calendar+Popover for date filters instead of native …
Marfuen May 18, 2026
359a1fb
fix(app): add background and border to date filter calendar popover
Marfuen May 18, 2026
9f49aae
feat(app): redesign date filters as dropdown popovers with presets
Marfuen May 19, 2026
28f7f41
fix(app): use rounded-md instead of rounded-full on date filter triggers
Marfuen May 19, 2026
72710f2
fix(app): auto-include deactivated members when offboard date filter …
Marfuen May 19, 2026
654a134
feat(app): add offboarding alert banner and Quick Actions offboarding…
Marfuen May 19, 2026
5e0ba5d
fix(app): move Offboarding tab to last position in employee detail page
Marfuen May 19, 2026
a14e816
feat(app): add Export evidence button to offboarding summary card
Marfuen May 19, 2026
4325208
feat(people): add offboarding evidence zip export with structured fol…
Marfuen May 19, 2026
4f61715
fix(app): make Export evidence button always visible with primary var…
Marfuen May 19, 2026
1071714
feat: add bulk export of all offboarding data from people page
Marfuen May 19, 2026
6a2d0e4
fix(app): move menu button to the right of Add User
Marfuen May 19, 2026
1b8b971
fix(app): shorten export menu item copy
Marfuen May 19, 2026
c0c7c52
fix(api): nest bulk export under offboarded-employees/ folder
Marfuen May 19, 2026
6b180c4
fix(api): prefix bulk export zip filename with organization name
Marfuen May 19, 2026
0974d39
fix(app): link banner to people page when multiple offboardings pending
Marfuen May 19, 2026
15bdf1e
fix(app): stop defaulting onboardDate to createdAt
Marfuen May 19, 2026
d030870
feat(api): set onboardDate from provider data during employee sync
Marfuen May 19, 2026
c8ec116
feat(api): backfill onboardDate on subsequent syncs for existing members
Marfuen May 19, 2026
ef5a848
Merge branch 'main' into mariano/cs-312-feature-add-personnel-employm…
Marfuen May 19, 2026
468b2e6
fix(api): add creationTime to local GoogleWorkspaceUser interface
Marfuen May 19, 2026
e15e8c1
fix: add in-flight guard to file upload + handle fetch errors in expo…
Marfuen May 19, 2026
cd52706
fix(app): add logoUrl to RevokedVendorRowProps type
Marfuen May 19, 2026
873b0a6
fix(app): remove asChild from PopoverTrigger — DS uses className dire…
Marfuen May 19, 2026
e577962
fix(app): wrap PopoverTrigger content in div for styling instead of c…
Marfuen May 19, 2026
dcc15b9
Merge branch 'main' into mariano/cs-312-feature-add-personnel-employm…
Marfuen May 20, 2026
d8a9025
fix(people): remove className/asChild from DS PopoverContent and Drop…
Marfuen May 20, 2026
4048333
fix(people): use render prop to avoid nested button in DropdownMenuTr…
Marfuen May 20, 2026
a1d26ff
fix(people): remove redundant wrapper divs from PopoverContent children
Marfuen May 20, 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
26 changes: 22 additions & 4 deletions apps/api/src/integration-platform/controllers/sync.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ interface GoogleWorkspaceUser {
isAdmin?: boolean;
suspended?: boolean;
orgUnitPath?: string;
creationTime?: string;
}

interface GoogleWorkspaceUsersResponse {
Expand Down Expand Up @@ -381,9 +382,12 @@ export class SyncController {
});

if (existingMember) {
// Never reactivate deactivated members — whether deactivated manually
// by an admin or by a previous sync, they should stay deactivated.
// Admins can reactivate manually if needed.
if (!existingMember.onboardDate && gwUser.creationTime) {
await db.member.update({
where: { id: existingMember.id },
data: { onboardDate: new Date(gwUser.creationTime) },
});
}
results.skipped++;
results.details.push({
email: normalizedEmail,
Expand All @@ -402,6 +406,7 @@ export class SyncController {
userId,
role: 'employee',
isActive: true,
...(gwUser.creationTime ? { onboardDate: new Date(gwUser.creationTime) } : {}),
},
});

Expand Down Expand Up @@ -839,6 +844,12 @@ export class SyncController {
});

if (existingMember) {
if (!existingMember.onboardDate && worker.start_date) {
await db.member.update({
where: { id: existingMember.id },
data: { onboardDate: new Date(worker.start_date) },
});
}
if (existingMember.deactivated) {
await db.member.update({
where: { id: existingMember.id },
Expand All @@ -865,6 +876,7 @@ export class SyncController {
userId,
role: 'employee',
isActive: true,
...(worker.start_date ? { onboardDate: new Date(worker.start_date) } : {}),
},
});
results.imported++;
Expand Down Expand Up @@ -1333,7 +1345,12 @@ export class SyncController {
});

if (existingMember) {
// If member was deactivated but is now active in JumpCloud, reactivate them
if (!existingMember.onboardDate && jcUser.created) {
await db.member.update({
where: { id: existingMember.id },
data: { onboardDate: new Date(jcUser.created) },
});
}
if (existingMember.deactivated) {
await db.member.update({
where: { id: existingMember.id },
Expand Down Expand Up @@ -1365,6 +1382,7 @@ export class SyncController {
userId,
role: 'employee',
isActive: true,
...(jcUser.created ? { onboardDate: new Date(jcUser.created) } : {}),
},
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,13 +185,17 @@ export class GenericEmployeeSyncService {
);
}

const needsOnboardDate =
!existingMember.onboardDate && employee.startDate;

if (existingMember.deactivated && allowReactivation) {
await db.member.update({
where: { id: existingMember.id },
data: {
deactivated: false,
isActive: true,
...(needsHeal ? { role: healedRole } : {}),
...(needsOnboardDate ? { onboardDate: new Date(employee.startDate!) } : {}),
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot May 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Validate employee.startDate before converting it to Date; malformed provider values can make sync writes fail.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/integration-platform/services/generic-employee-sync.service.ts, line 198:

<comment>Validate `employee.startDate` before converting it to `Date`; malformed provider values can make sync writes fail.</comment>

<file context>
@@ -185,13 +185,17 @@ export class GenericEmployeeSyncService {
                 deactivated: false,
                 isActive: true,
                 ...(needsHeal ? { role: healedRole } : {}),
+                ...(needsOnboardDate ? { onboardDate: new Date(employee.startDate!) } : {}),
               },
             });
</file context>
Suggested change
...(needsOnboardDate ? { onboardDate: new Date(employee.startDate!) } : {}),
...(needsOnboardDate && !Number.isNaN(Date.parse(employee.startDate!)) ? { onboardDate: new Date(employee.startDate!) } : {}),
Fix with Cubic

},
});
results.reactivated++;
Expand All @@ -200,10 +204,13 @@ export class GenericEmployeeSyncService {
status: 'reactivated',
});
} else {
if (needsHeal) {
if (needsHeal || needsOnboardDate) {
await db.member.update({
where: { id: existingMember.id },
data: { role: healedRole },
data: {
...(needsHeal ? { role: healedRole } : {}),
...(needsOnboardDate ? { onboardDate: new Date(employee.startDate!) } : {}),
},
});
}
results.skipped++;
Expand Down Expand Up @@ -231,6 +238,7 @@ export class GenericEmployeeSyncService {
userId: existingUser.id,
role: sanitizedRole,
isActive: true,
...(employee.startDate ? { onboardDate: new Date(employee.startDate) } : {}),
},
});

Expand Down Expand Up @@ -294,7 +302,11 @@ export class GenericEmployeeSyncService {
try {
await db.member.update({
where: { id: member.id },
data: { deactivated: true, isActive: false },
data: {
deactivated: true,
isActive: false,
offboardDate: member.offboardDate ?? new Date(),
},
});
results.deactivated++;
results.details.push({
Expand Down
233 changes: 233 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,233 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { AttachmentEntityType, db } from '@db';
import { AttachmentsService } from '../attachments/attachments.service';

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

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

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

const vendorList = await Promise.all(
vendors.map(async (vendor) => {
const revocation = revocationMap.get(vendor.id);
const domain = vendor.website?.replace(/^https?:\/\//, '').replace(/\/.*$/, '') ?? null;
const evidence = revocation
? await this.attachmentsService.getAttachments(
organizationId,
revocation.id,
AttachmentEntityType.offboarding_checklist,
)
: [];
return {
vendorId: vendor.id,
vendorName: vendor.name,
logoUrl: vendor.logoUrl ?? (domain ? `https://img.logo.dev/${domain}?token=pk_X-1ZO13GSgeOoUrIuJ6GMQ&size=64` : null),
revoked: !!revocation,
revokedAt: revocation?.revokedAt ?? null,
revokedBy: revocation?.revokedBy ?? null,
notes: revocation?.notes ?? null,
evidence,
};
}),
);

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

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

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

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

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

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

if (evidence) {
await this.attachmentsService.uploadAttachment(
organizationId,
revocation.id,
AttachmentEntityType.offboarding_checklist,
evidence,
revokedById,
);
}

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

return revocation;
}

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

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

await db.offboardingAccessRevocation.delete({
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot May 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Undoing a revocation should delete its uploaded evidence to avoid orphaned attachment records/files.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/offboarding-checklist/access-revocation.service.ts, line 143:

<comment>Undoing a revocation should delete its uploaded evidence to avoid orphaned attachment records/files.</comment>

<file context>
@@ -0,0 +1,233 @@
+      throw new NotFoundException('Revocation record not found');
+    }
+
+    await db.offboardingAccessRevocation.delete({
+      where: { id: revocation.id },
+    });
</file context>
Fix with Cubic

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(
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot May 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Use direct count queries in completion sync instead of calling getAccessRevocations, which does expensive attachment loading.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/offboarding-checklist/access-revocation.service.ts, line 204:

<comment>Use direct count queries in completion sync instead of calling `getAccessRevocations`, which does expensive attachment loading.</comment>

<file context>
@@ -0,0 +1,233 @@
+      return;
+    }
+
+    const { totalVendors, revokedCount } = await this.getAccessRevocations(
+      organizationId,
+      memberId,
</file context>
Fix with Cubic

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 },
});
}
}
}
Loading
Loading