Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
48 changes: 33 additions & 15 deletions apps/api/src/integration-platform/controllers/sync.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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 } : {}),
},
});

Expand Down Expand Up @@ -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({
Expand All @@ -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++;
Expand Down Expand Up @@ -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({
Expand All @@ -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 } : {}),
},
});

Expand Down
16 changes: 16 additions & 0 deletions apps/api/src/offboarding-checklist/access-revocation.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
});
Expand Down Expand Up @@ -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 },
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
BadRequestException,
Controller,
Get,
Post,
Expand Down Expand Up @@ -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' })
Expand Down Expand Up @@ -169,7 +177,7 @@ export class OffboardingChecklistController {
organizationId,
memberId,
templateItemId,
completedById: authContext.userId!,
completedById: this.requireUserId(authContext),
dto,
});
}
Expand Down Expand Up @@ -202,7 +210,7 @@ export class OffboardingChecklistController {
memberId,
templateItemId,
uploadDto,
userId: authContext.userId!,
userId: this.requireUserId(authContext),
});
}

Expand Down Expand Up @@ -234,7 +242,7 @@ export class OffboardingChecklistController {
return this.offboardingChecklistService.revokeAllVendorAccess({
organizationId,
memberId,
revokedById: authContext.userId!,
revokedById: this.requireUserId(authContext),
});
}

Expand All @@ -250,14 +258,19 @@ 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;
return this.offboardingChecklistService.revokeVendorAccess({
organizationId,
memberId,
vendorId,
revokedById: authContext.userId!,
revokedById: this.requireUserId(authContext),
notes: body?.notes,
evidence,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({});
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/people/people.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@ interface PendingResponse {

export function OffboardingBanner() {
const params = useParams<{ orgId: string }>();
const { data } = useApiSWR<PendingResponse>(
const { data, error } = useApiSWR<PendingResponse>(
'/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`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -263,8 +263,9 @@ function VendorRow({ vendor, canEdit, isProcessing, onRevoke }: VendorRowProps)
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
onClick={() => !isProcessing && fileInputRef.current?.click()}
iconLeft={<DocumentAttachment size={12} />}
disabled={isProcessing}
>
Attach evidence
</Button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export function OffboardingSummaryCard({
window.open(
`/api/offboarding-export?memberId=${encodeURIComponent(memberId)}`,
'_blank',
'noopener,noreferrer',
);
}}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ export function PeoplePageTabs({
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
window.open('/api/offboarding-export?all=true', '_blank');
window.open('/api/offboarding-export?all=true', '_blank', 'noopener,noreferrer');
}}
>
<Download size={14} className="mr-2" />
Expand Down
8 changes: 4 additions & 4 deletions packages/db/prisma/schema/offboarding-checklist.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand All @@ -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])
Expand Down
Loading