diff --git a/apps/api/src/integration-platform/controllers/sync.controller.ts b/apps/api/src/integration-platform/controllers/sync.controller.ts index 897620f1b..335630d26 100644 --- a/apps/api/src/integration-platform/controllers/sync.controller.ts +++ b/apps/api/src/integration-platform/controllers/sync.controller.ts @@ -383,10 +383,14 @@ export class SyncController { if (existingMember) { if (!existingMember.onboardDate && gwUser.creationTime) { - await db.member.update({ - where: { id: existingMember.id }, - data: { onboardDate: new Date(gwUser.creationTime) }, - }); + const parsed = new Date(gwUser.creationTime); + const onboardDate = isNaN(parsed.getTime()) ? undefined : parsed; + if (onboardDate) { + await db.member.update({ + where: { id: existingMember.id }, + data: { onboardDate }, + }); + } } results.skipped++; results.details.push({ @@ -400,13 +404,15 @@ export class SyncController { } // Create member - always as employee, admins can be promoted manually + const gwParsed = gwUser.creationTime ? new Date(gwUser.creationTime) : null; + const gwOnboardDate = gwParsed && !isNaN(gwParsed.getTime()) ? gwParsed : undefined; await db.member.create({ data: { organizationId, userId, role: 'employee', isActive: true, - ...(gwUser.creationTime ? { onboardDate: new Date(gwUser.creationTime) } : {}), + ...(gwOnboardDate ? { onboardDate: gwOnboardDate } : {}), }, }); @@ -845,10 +851,14 @@ 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) }, - }); + const parsed = new Date(worker.start_date); + const onboardDate = isNaN(parsed.getTime()) ? undefined : parsed; + if (onboardDate) { + await db.member.update({ + where: { id: existingMember.id }, + data: { onboardDate }, + }); + } } if (existingMember.deactivated) { await db.member.update({ @@ -870,13 +880,15 @@ export class SyncController { }); } } else { + const ripplingParsed = worker.start_date ? new Date(worker.start_date) : null; + const ripplingOnboardDate = ripplingParsed && !isNaN(ripplingParsed.getTime()) ? ripplingParsed : undefined; await db.member.create({ data: { organizationId, userId, role: 'employee', isActive: true, - ...(worker.start_date ? { onboardDate: new Date(worker.start_date) } : {}), + ...(ripplingOnboardDate ? { onboardDate: ripplingOnboardDate } : {}), }, }); results.imported++; @@ -1346,10 +1358,14 @@ export class SyncController { if (existingMember) { if (!existingMember.onboardDate && jcUser.created) { - await db.member.update({ - where: { id: existingMember.id }, - data: { onboardDate: new Date(jcUser.created) }, - }); + const parsed = new Date(jcUser.created); + const onboardDate = isNaN(parsed.getTime()) ? undefined : parsed; + if (onboardDate) { + await db.member.update({ + where: { id: existingMember.id }, + data: { onboardDate }, + }); + } } if (existingMember.deactivated) { await db.member.update({ @@ -1376,13 +1392,15 @@ export class SyncController { } // Create member - always as employee, admins can be promoted manually + const jcParsed = jcUser.created ? new Date(jcUser.created) : null; + const jcOnboardDate = jcParsed && !isNaN(jcParsed.getTime()) ? jcParsed : undefined; await db.member.create({ data: { organizationId, userId, role: 'employee', isActive: true, - ...(jcUser.created ? { onboardDate: new Date(jcUser.created) } : {}), + ...(jcOnboardDate ? { onboardDate: jcOnboardDate } : {}), }, }); diff --git a/apps/api/src/offboarding-checklist/access-revocation.service.ts b/apps/api/src/offboarding-checklist/access-revocation.service.ts index e515e283e..bfeb1528d 100644 --- a/apps/api/src/offboarding-checklist/access-revocation.service.ts +++ b/apps/api/src/offboarding-checklist/access-revocation.service.ts @@ -73,6 +73,14 @@ export class AccessRevocationService { notes?: string; evidence?: { fileName: string; fileType: string; fileData: string }; }) { + const member = await db.member.findFirst({ + where: { id: memberId, organizationId }, + }); + + if (!member) { + throw new NotFoundException('Member not found in this organization'); + } + const vendor = await db.vendor.findFirst({ where: { id: vendorId, organizationId }, }); @@ -173,6 +181,14 @@ export class AccessRevocationService { memberId: string; revokedById: string; }) { + const member = await db.member.findFirst({ + where: { id: memberId, organizationId }, + }); + + if (!member) { + throw new NotFoundException('Member not found in this organization'); + } + const vendors = await db.vendor.findMany({ where: { organizationId }, select: { id: true }, diff --git a/apps/api/src/offboarding-checklist/offboarding-checklist.controller.ts b/apps/api/src/offboarding-checklist/offboarding-checklist.controller.ts index 8b6517486..32d042499 100644 --- a/apps/api/src/offboarding-checklist/offboarding-checklist.controller.ts +++ b/apps/api/src/offboarding-checklist/offboarding-checklist.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Controller, Get, Post, @@ -34,6 +35,13 @@ export class OffboardingChecklistController { private readonly offboardingExportService: OffboardingExportService, ) {} + private requireUserId(authContext: AuthContextType): string { + if (!authContext.userId) { + throw new BadRequestException('User context required'); + } + return authContext.userId; + } + @Get('pending') @RequirePermission('member', 'read') @ApiOperation({ summary: 'Get members with pending offboarding checklists' }) @@ -169,7 +177,7 @@ export class OffboardingChecklistController { organizationId, memberId, templateItemId, - completedById: authContext.userId!, + completedById: this.requireUserId(authContext), dto, }); } @@ -202,7 +210,7 @@ export class OffboardingChecklistController { memberId, templateItemId, uploadDto, - userId: authContext.userId!, + userId: this.requireUserId(authContext), }); } @@ -234,7 +242,7 @@ export class OffboardingChecklistController { return this.offboardingChecklistService.revokeAllVendorAccess({ organizationId, memberId, - revokedById: authContext.userId!, + revokedById: this.requireUserId(authContext), }); } @@ -250,6 +258,11 @@ export class OffboardingChecklistController { @AuthContext() authContext: AuthContextType, @Body() body: { notes?: string; fileName?: string; fileType?: string; fileData?: string }, ) { + const evidenceFields = [body?.fileName, body?.fileType, body?.fileData]; + const providedCount = evidenceFields.filter(Boolean).length; + if (providedCount > 0 && providedCount < 3) { + throw new BadRequestException('fileName, fileType, and fileData must all be provided together'); + } const evidence = body?.fileName && body?.fileType && body?.fileData ? { fileName: body.fileName, fileType: body.fileType, fileData: body.fileData } : undefined; @@ -257,7 +270,7 @@ export class OffboardingChecklistController { organizationId, memberId, vendorId, - revokedById: authContext.userId!, + revokedById: this.requireUserId(authContext), notes: body?.notes, evidence, }); diff --git a/apps/api/src/offboarding-checklist/offboarding-checklist.service.spec.ts b/apps/api/src/offboarding-checklist/offboarding-checklist.service.spec.ts index b277a5f58..af3b1b91a 100644 --- a/apps/api/src/offboarding-checklist/offboarding-checklist.service.spec.ts +++ b/apps/api/src/offboarding-checklist/offboarding-checklist.service.spec.ts @@ -509,7 +509,7 @@ describe('OffboardingChecklistService', () => { describe('undoVendorRevocation', () => { it('deletes revocation record', async () => { - mockDb.offboardingAccessRevocation.findUnique.mockResolvedValue({ + mockDb.offboardingAccessRevocation.findFirst.mockResolvedValue({ id: 'oar_1', }); mockDb.offboardingAccessRevocation.delete.mockResolvedValue({}); diff --git a/apps/api/src/people/people.controller.ts b/apps/api/src/people/people.controller.ts index ecc345b73..c6a42263b 100644 --- a/apps/api/src/people/people.controller.ts +++ b/apps/api/src/people/people.controller.ts @@ -596,6 +596,9 @@ export class PeopleController { @AuthContext() authContext: AuthContextType, @Body() uploadDto: UploadAttachmentDto, ) { + if (!authContext.userId) { + throw new BadRequestException('User context required for this operation'); + } const entityType = this.resolveEventType(eventType); await this.peopleService.findById(memberId, organizationId); return this.attachmentsService.uploadAttachment( diff --git a/apps/app/src/app/(app)/[orgId]/overview/components/OffboardingBanner.tsx b/apps/app/src/app/(app)/[orgId]/overview/components/OffboardingBanner.tsx index 09bf0e55b..5537fbdb9 100644 --- a/apps/app/src/app/(app)/[orgId]/overview/components/OffboardingBanner.tsx +++ b/apps/app/src/app/(app)/[orgId]/overview/components/OffboardingBanner.tsx @@ -17,13 +17,13 @@ interface PendingResponse { export function OffboardingBanner() { const params = useParams<{ orgId: string }>(); - const { data } = useApiSWR( + const { data, error } = useApiSWR( '/v1/offboarding-checklist/pending', ); const members = data?.data?.members ?? []; const [dismissed, setDismissed] = useState(false); - if (dismissed || members.length === 0) return null; + if (error || dismissed || members.length === 0) return null; const link = members.length === 1 ? `/${params.orgId}/people/${members[0].memberId}?tab=offboarding` diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/AccessRevocationList.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/AccessRevocationList.tsx index 78aede33d..bb1449f11 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/AccessRevocationList.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/AccessRevocationList.tsx @@ -263,8 +263,9 @@ function VendorRow({ vendor, canEdit, isProcessing, onRevoke }: VendorRowProps) diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/OffboardingSummaryCard.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/OffboardingSummaryCard.tsx index cdd0cc7a5..1334d7555 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/OffboardingSummaryCard.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/OffboardingSummaryCard.tsx @@ -86,6 +86,7 @@ export function OffboardingSummaryCard({ window.open( `/api/offboarding-export?memberId=${encodeURIComponent(memberId)}`, '_blank', + 'noopener,noreferrer', ); }} > diff --git a/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx b/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx index 765eae532..344945129 100644 --- a/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx @@ -162,7 +162,7 @@ export function PeoplePageTabs({ { - window.open('/api/offboarding-export?all=true', '_blank'); + window.open('/api/offboarding-export?all=true', '_blank', 'noopener,noreferrer'); }} > diff --git a/packages/db/prisma/schema/offboarding-checklist.prisma b/packages/db/prisma/schema/offboarding-checklist.prisma index 9a3ea5393..4d21f2b59 100644 --- a/packages/db/prisma/schema/offboarding-checklist.prisma +++ b/packages/db/prisma/schema/offboarding-checklist.prisma @@ -23,14 +23,14 @@ model OffboardingChecklistCompletion { organizationId String memberId String templateItemId String? - completedById 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: SetNull) - completedBy User @relation("OffboardingChecklistCompletedBy", fields: [completedById], references: [id], onDelete: Cascade) + completedBy User? @relation("OffboardingChecklistCompletedBy", fields: [completedById], references: [id], onDelete: SetNull) @@unique([memberId, templateItemId]) @@index([organizationId]) @@ -42,14 +42,14 @@ model OffboardingAccessRevocation { organizationId String memberId String vendorId String - revokedById String + revokedById String? revokedAt DateTime @default(now()) notes String? organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) member Member @relation(fields: [memberId], references: [id], onDelete: Cascade) vendor Vendor @relation(fields: [vendorId], references: [id], onDelete: Cascade) - revokedBy User @relation("AccessRevocationRevokedBy", fields: [revokedById], references: [id], onDelete: Cascade) + revokedBy User? @relation("AccessRevocationRevokedBy", fields: [revokedById], references: [id], onDelete: SetNull) @@unique([memberId, vendorId]) @@index([organizationId])