diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 31be83c2c..2645cb341 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -33,6 +33,7 @@ import { CloudSecurityModule } from './cloud-security/cloud-security.module'; import { BrowserbaseModule } from './browserbase/browserbase.module'; import { TaskManagementModule } from './task-management/task-management.module'; import { AssistantChatModule } from './assistant-chat/assistant-chat.module'; +import { OrgChartModule } from './org-chart/org-chart.module'; import { TrainingModule } from './training/training.module'; @Module({ @@ -80,6 +81,7 @@ import { TrainingModule } from './training/training.module'; TaskManagementModule, AssistantChatModule, TrainingModule, + OrgChartModule, ], controllers: [AppController], providers: [ diff --git a/apps/api/src/org-chart/dto/upload-org-chart.dto.ts b/apps/api/src/org-chart/dto/upload-org-chart.dto.ts new file mode 100644 index 000000000..857109bec --- /dev/null +++ b/apps/api/src/org-chart/dto/upload-org-chart.dto.ts @@ -0,0 +1,22 @@ +import { IsNotEmpty, IsString, Matches } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class UploadOrgChartDto { + @ApiProperty({ description: 'Original file name' }) + @IsString() + @IsNotEmpty() + fileName: string; + + @ApiProperty({ description: 'MIME type of the file (e.g. image/png)' }) + @IsString() + @IsNotEmpty() + @Matches(/^[a-zA-Z0-9\-]+\/[a-zA-Z0-9\-\+\.]+$/, { + message: 'Invalid MIME type format', + }) + fileType: string; + + @ApiProperty({ description: 'Base64-encoded file data' }) + @IsString() + @IsNotEmpty() + fileData: string; +} diff --git a/apps/api/src/org-chart/dto/upsert-org-chart.dto.ts b/apps/api/src/org-chart/dto/upsert-org-chart.dto.ts new file mode 100644 index 000000000..b88207336 --- /dev/null +++ b/apps/api/src/org-chart/dto/upsert-org-chart.dto.ts @@ -0,0 +1,17 @@ +import { IsArray, IsOptional, IsString } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class UpsertOrgChartDto { + @ApiPropertyOptional({ description: 'Name of the org chart' }) + @IsOptional() + @IsString() + name?: string; + + @ApiProperty({ description: 'React Flow nodes array', type: [Object] }) + @IsArray() + nodes: any[]; + + @ApiProperty({ description: 'React Flow edges array', type: [Object] }) + @IsArray() + edges: any[]; +} diff --git a/apps/api/src/org-chart/org-chart.controller.ts b/apps/api/src/org-chart/org-chart.controller.ts new file mode 100644 index 000000000..c9cea1ddf --- /dev/null +++ b/apps/api/src/org-chart/org-chart.controller.ts @@ -0,0 +1,78 @@ +import { + Controller, + Get, + Put, + Post, + Delete, + Body, + UseGuards, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { + ApiHeader, + ApiOperation, + ApiResponse, + ApiSecurity, + ApiTags, +} from '@nestjs/swagger'; +import { OrganizationId } from '../auth/auth-context.decorator'; +import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; +import { OrgChartService } from './org-chart.service'; +import { UpsertOrgChartDto } from './dto/upsert-org-chart.dto'; +import { UploadOrgChartDto } from './dto/upload-org-chart.dto'; + +@ApiTags('Org Chart') +@Controller({ path: 'org-chart', version: '1' }) +@UseGuards(HybridAuthGuard) +@ApiSecurity('apikey') +@ApiHeader({ + name: 'X-Organization-Id', + description: + 'Organization ID (required for session auth, optional for API key auth)', + required: false, +}) +export class OrgChartController { + constructor(private readonly orgChartService: OrgChartService) {} + + @Get() + @ApiOperation({ summary: 'Get the organization chart' }) + @ApiResponse({ status: 200, description: 'The organization chart' }) + async getOrgChart(@OrganizationId() organizationId: string) { + return await this.orgChartService.findByOrganization(organizationId); + } + + @Put() + @ApiOperation({ summary: 'Create or update an interactive organization chart' }) + @ApiResponse({ status: 200, description: 'The saved organization chart' }) + @UsePipes(new ValidationPipe({ whitelist: false, transform: false })) + async upsertOrgChart( + @OrganizationId() organizationId: string, + @Body() body: Record, + ) { + const dto: UpsertOrgChartDto = { + name: typeof body?.name === 'string' ? body.name : undefined, + nodes: Array.isArray(body?.nodes) ? body.nodes : [], + edges: Array.isArray(body?.edges) ? body.edges : [], + }; + return await this.orgChartService.upsertInteractive(organizationId, dto); + } + + @Post('upload') + @ApiOperation({ summary: 'Upload an image as the organization chart' }) + @ApiResponse({ status: 201, description: 'The uploaded organization chart' }) + @UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })) + async uploadOrgChart( + @OrganizationId() organizationId: string, + @Body() dto: UploadOrgChartDto, + ) { + return await this.orgChartService.uploadImage(organizationId, dto); + } + + @Delete() + @ApiOperation({ summary: 'Delete the organization chart' }) + @ApiResponse({ status: 200, description: 'Deletion confirmation' }) + async deleteOrgChart(@OrganizationId() organizationId: string) { + return await this.orgChartService.delete(organizationId); + } +} diff --git a/apps/api/src/org-chart/org-chart.module.ts b/apps/api/src/org-chart/org-chart.module.ts new file mode 100644 index 000000000..0c6a7053a --- /dev/null +++ b/apps/api/src/org-chart/org-chart.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { OrgChartController } from './org-chart.controller'; +import { OrgChartService } from './org-chart.service'; + +@Module({ + imports: [AuthModule], + controllers: [OrgChartController], + providers: [OrgChartService], + exports: [OrgChartService], +}) +export class OrgChartModule {} diff --git a/apps/api/src/org-chart/org-chart.service.ts b/apps/api/src/org-chart/org-chart.service.ts new file mode 100644 index 000000000..b70ac4a41 --- /dev/null +++ b/apps/api/src/org-chart/org-chart.service.ts @@ -0,0 +1,280 @@ +import { + BadRequestException, + Injectable, + InternalServerErrorException, + Logger, +} from '@nestjs/common'; +import { + DeleteObjectCommand, + GetObjectCommand, + PutObjectCommand, + S3Client, +} from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { db } from '@trycompai/db'; +import { s3Client, BUCKET_NAME } from '@/app/s3'; +import type { UpsertOrgChartDto } from './dto/upsert-org-chart.dto'; +import type { UploadOrgChartDto } from './dto/upload-org-chart.dto'; + +@Injectable() +export class OrgChartService { + private readonly logger = new Logger(OrgChartService.name); + private s3Client: S3Client | null; + private bucketName: string | undefined; + private readonly SIGNED_URL_EXPIRY = 900; // 15 minutes + private readonly MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024; // 100MB + private readonly ALLOWED_UPLOAD_MIME_TYPES = [ + 'image/png', + 'image/jpeg', + 'image/gif', + 'image/webp', + 'image/svg+xml', + 'image/bmp', + 'image/tiff', + 'application/pdf', + ]; + + constructor() { + this.s3Client = s3Client ?? null; + this.bucketName = BUCKET_NAME; + } + + async findByOrganization(organizationId: string) { + try { + const chart = await db.organizationChart.findUnique({ + where: { organizationId }, + }); + + if (!chart) { + return null; + } + + // If there's an uploaded image, generate a presigned URL + let signedImageUrl: string | null = null; + if (chart.type === 'uploaded' && chart.uploadedImageUrl) { + signedImageUrl = await this.getSignedUrl(chart.uploadedImageUrl); + } + + return { + ...chart, + signedImageUrl, + }; + } catch (error) { + this.logger.error( + `Failed to fetch org chart for organization ${organizationId}:`, + error, + ); + throw new InternalServerErrorException('Failed to fetch org chart'); + } + } + + async upsertInteractive( + organizationId: string, + data: UpsertOrgChartDto, + ) { + try { + this.logger.log( + `[OrgChart API] Saving for org ${organizationId}: nodes=${data.nodes?.length ?? 'null'}, edges=${data.edges?.length ?? 'null'}`, + ); + + // Check for an existing uploaded image before the upsert so we can + // clean it up from S3 *after* the DB write succeeds. + const existing = await db.organizationChart.findUnique({ + where: { organizationId }, + }); + const previousImageKey = existing?.uploadedImageUrl ?? null; + + const chart = await db.organizationChart.upsert({ + where: { organizationId }, + create: { + organizationId, + type: 'interactive', + nodes: data.nodes as any, + edges: data.edges as any, + ...(data.name && { name: data.name }), + }, + update: { + type: 'interactive', + nodes: data.nodes as any, + edges: data.edges as any, + uploadedImageUrl: null, + ...(data.name && { name: data.name }), + }, + }); + + // Delete the old S3 object only after the DB update succeeded, + // so a DB failure doesn't orphan the image permanently. + if (previousImageKey) { + await this.deleteS3Object(previousImageKey); + } + + this.logger.log( + `Upserted interactive org chart for organization ${organizationId}`, + ); + return chart; + } catch (error) { + this.logger.error( + `Failed to upsert org chart for organization ${organizationId}:`, + error, + ); + throw new InternalServerErrorException('Failed to save org chart'); + } + } + + async uploadImage( + organizationId: string, + data: UploadOrgChartDto, + ) { + if (!this.s3Client || !this.bucketName) { + throw new InternalServerErrorException( + 'File upload service is not available', + ); + } + + try { + // Validate MIME type is an allowed upload type (images + PDF) + const normalizedType = data.fileType.toLowerCase(); + if (!this.ALLOWED_UPLOAD_MIME_TYPES.includes(normalizedType)) { + throw new BadRequestException( + `File type '${data.fileType}' is not allowed. Supported formats: PNG, JPG, GIF, WebP, SVG, BMP, TIFF, PDF.`, + ); + } + + const fileBuffer = Buffer.from(data.fileData, 'base64'); + + if (fileBuffer.length > this.MAX_FILE_SIZE_BYTES) { + throw new BadRequestException( + 'File exceeds the 100MB size limit', + ); + } + + // Delete old image if it exists + const existing = await db.organizationChart.findUnique({ + where: { organizationId }, + }); + if (existing?.uploadedImageUrl) { + await this.deleteS3Object(existing.uploadedImageUrl); + } + + const timestamp = Date.now(); + const sanitizedFileName = data.fileName.replace(/[^a-zA-Z0-9.-]/g, '_'); + const s3Key = `${organizationId}/org-chart/${timestamp}-${sanitizedFileName}`; + + const putCommand = new PutObjectCommand({ + Bucket: this.bucketName, + Key: s3Key, + Body: fileBuffer, + ContentType: data.fileType, + }); + await this.s3Client.send(putCommand); + + const chart = await db.organizationChart.upsert({ + where: { organizationId }, + create: { + organizationId, + type: 'uploaded', + uploadedImageUrl: s3Key, + }, + update: { + type: 'uploaded', + uploadedImageUrl: s3Key, + nodes: [], + edges: [], + }, + }); + + const signedImageUrl = await this.getSignedUrl(s3Key); + + this.logger.log( + `Uploaded org chart image for organization ${organizationId}`, + ); + + return { + ...chart, + signedImageUrl, + }; + } catch (error) { + if ( + error instanceof BadRequestException || + error instanceof InternalServerErrorException + ) { + throw error; + } + this.logger.error( + `Failed to upload org chart image for organization ${organizationId}:`, + error, + ); + throw new InternalServerErrorException( + 'Failed to upload org chart image', + ); + } + } + + async delete(organizationId: string) { + try { + const existing = await db.organizationChart.findUnique({ + where: { organizationId }, + }); + + if (!existing) { + return { message: 'No org chart found' }; + } + + // Delete S3 image if applicable + if (existing.uploadedImageUrl) { + await this.deleteS3Object(existing.uploadedImageUrl); + } + + await db.organizationChart.delete({ + where: { organizationId }, + }); + + this.logger.log( + `Deleted org chart for organization ${organizationId}`, + ); + + return { message: 'Org chart deleted successfully' }; + } catch (error) { + this.logger.error( + `Failed to delete org chart for organization ${organizationId}:`, + error, + ); + throw new InternalServerErrorException('Failed to delete org chart'); + } + } + + private async getSignedUrl(s3Key: string): Promise { + if (!this.s3Client || !this.bucketName) { + return null; + } + + try { + const getCommand = new GetObjectCommand({ + Bucket: this.bucketName, + Key: s3Key, + }); + return await getSignedUrl(this.s3Client, getCommand, { + expiresIn: this.SIGNED_URL_EXPIRY, + }); + } catch (error) { + this.logger.error(`Failed to generate signed URL for ${s3Key}:`, error); + return null; + } + } + + private async deleteS3Object(s3Key: string): Promise { + if (!this.s3Client || !this.bucketName) { + return; + } + + try { + const deleteCommand = new DeleteObjectCommand({ + Bucket: this.bucketName, + Key: s3Key, + }); + await this.s3Client.send(deleteCommand); + } catch (error) { + this.logger.error(`Failed to delete S3 object ${s3Key}:`, error); + } + } +} diff --git a/apps/api/src/people/dto/create-people.dto.ts b/apps/api/src/people/dto/create-people.dto.ts index 4c36b7a9a..7d540bd85 100644 --- a/apps/api/src/people/dto/create-people.dto.ts +++ b/apps/api/src/people/dto/create-people.dto.ts @@ -50,4 +50,13 @@ export class CreatePeopleDto { @IsOptional() @IsNumber() fleetDmLabelId?: number; + + @ApiProperty({ + description: 'Job title for the member', + example: 'Software Engineer', + required: false, + }) + @IsOptional() + @IsString() + jobTitle?: string; } diff --git a/apps/api/src/people/dto/people-responses.dto.ts b/apps/api/src/people/dto/people-responses.dto.ts index be8723a26..28d7cabdf 100644 --- a/apps/api/src/people/dto/people-responses.dto.ts +++ b/apps/api/src/people/dto/people-responses.dto.ts @@ -91,6 +91,13 @@ export class PeopleResponseDto { }) department: Departments; + @ApiProperty({ + description: 'Job title for the member', + example: 'Software Engineer', + nullable: true, + }) + jobTitle: string | null; + @ApiProperty({ description: 'Whether member is active', example: true, diff --git a/apps/api/src/people/utils/member-queries.ts b/apps/api/src/people/utils/member-queries.ts index 3e6849247..a5b7a7df6 100644 --- a/apps/api/src/people/utils/member-queries.ts +++ b/apps/api/src/people/utils/member-queries.ts @@ -17,6 +17,7 @@ export class MemberQueries { role: true, createdAt: true, department: true, + jobTitle: true, isActive: true, fleetDmLabelId: true, user: { @@ -77,6 +78,7 @@ export class MemberQueries { department: createData.department || 'none', isActive: createData.isActive ?? true, fleetDmLabelId: createData.fleetDmLabelId || null, + jobTitle: createData.jobTitle || null, }, select: this.MEMBER_SELECT, }); @@ -170,6 +172,7 @@ export class MemberQueries { department: member.department || 'none', isActive: member.isActive ?? true, fleetDmLabelId: member.fleetDmLabelId || null, + jobTitle: member.jobTitle || null, })); // Perform bulk insert diff --git a/apps/api/src/policies/dto/ai-suggest-policy.dto.ts b/apps/api/src/policies/dto/ai-suggest-policy.dto.ts index 351a83801..cc2f744d5 100644 --- a/apps/api/src/policies/dto/ai-suggest-policy.dto.ts +++ b/apps/api/src/policies/dto/ai-suggest-policy.dto.ts @@ -1,4 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; import { IsString, IsOptional, IsArray } from 'class-validator'; export class AISuggestPolicyRequestDto { @@ -29,5 +30,6 @@ export class AISuggestPolicyRequestDto { }) @IsOptional() @IsArray() + @Transform(({ value }) => value) chatHistory?: Array<{ role: 'user' | 'assistant'; content: string }>; } diff --git a/apps/api/src/policies/dto/create-policy.dto.ts b/apps/api/src/policies/dto/create-policy.dto.ts index 776a0763e..0aa576e23 100644 --- a/apps/api/src/policies/dto/create-policy.dto.ts +++ b/apps/api/src/policies/dto/create-policy.dto.ts @@ -1,4 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; import { IsString, IsOptional, @@ -81,6 +82,7 @@ export class CreatePolicyDto { items: { type: 'object', additionalProperties: true }, }) @IsArray() + @Transform(({ value }) => value) content: unknown[]; @ApiProperty({ diff --git a/apps/api/src/policies/dto/version.dto.ts b/apps/api/src/policies/dto/version.dto.ts index d0f894f95..b69297e02 100644 --- a/apps/api/src/policies/dto/version.dto.ts +++ b/apps/api/src/policies/dto/version.dto.ts @@ -1,4 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; import { IsArray, IsBoolean, IsOptional, IsString } from 'class-validator'; export class CreateVersionDto { @@ -36,6 +37,7 @@ export class UpdateVersionContentDto { items: { type: 'object', additionalProperties: true }, }) @IsArray() + @Transform(({ value }) => value) content: unknown[]; } diff --git a/apps/api/src/policies/policies.controller.ts b/apps/api/src/policies/policies.controller.ts index 2f27305fd..9ff053d71 100644 --- a/apps/api/src/policies/policies.controller.ts +++ b/apps/api/src/policies/policies.controller.ts @@ -52,6 +52,7 @@ import { VERSION_BODIES } from './schemas/version-bodies'; import { CREATE_POLICY_VERSION_RESPONSES, DELETE_VERSION_RESPONSES, + GET_POLICY_VERSION_BY_ID_RESPONSES, GET_POLICY_VERSIONS_RESPONSES, PUBLISH_VERSION_RESPONSES, SET_ACTIVE_VERSION_RESPONSES, @@ -265,6 +266,37 @@ export class PoliciesController { }; } + @Get(':id/versions/:versionId') + @ApiOperation(VERSION_OPERATIONS.getPolicyVersionById) + @ApiParam(VERSION_PARAMS.policyId) + @ApiParam(VERSION_PARAMS.versionId) + @ApiResponse(GET_POLICY_VERSION_BY_ID_RESPONSES[200]) + @ApiResponse(GET_POLICY_VERSION_BY_ID_RESPONSES[401]) + @ApiResponse(GET_POLICY_VERSION_BY_ID_RESPONSES[404]) + async getPolicyVersionById( + @Param('id') id: string, + @Param('versionId') versionId: string, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + const data = await this.policiesService.getVersionById( + id, + versionId, + organizationId, + ); + + return { + data, + authType: authContext.authType, + ...(authContext.userId && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), + }; + } + @Post(':id/versions') @ApiOperation(VERSION_OPERATIONS.createPolicyVersion) @ApiParam(VERSION_PARAMS.policyId) diff --git a/apps/api/src/policies/policies.service.ts b/apps/api/src/policies/policies.service.ts index a6cc5e961..cc2b019de 100644 --- a/apps/api/src/policies/policies.service.ts +++ b/apps/api/src/policies/policies.service.ts @@ -222,19 +222,6 @@ export class PoliciesService { updateData: UpdatePolicyDto, ) { try { - // First check if the policy exists and belongs to the organization - const existingPolicy = await db.policy.findFirst({ - where: { - id, - organizationId, - }, - select: { id: true, name: true }, - }); - - if (!existingPolicy) { - throw new NotFoundException(`Policy with ID ${id} not found`); - } - // Prepare update data with special handling for status changes const updatePayload: Record = { ...updateData }; @@ -249,35 +236,70 @@ export class PoliciesService { } // Coerce content to Prisma JSON[] input if provided - if (Array.isArray(updateData.content)) { - updatePayload.content = updateData.content as Prisma.InputJsonValue[]; + const contentValue = Array.isArray(updateData.content) + ? (updateData.content as Prisma.InputJsonValue[]) + : null; + + if (contentValue) { + updatePayload.content = contentValue; } - // Update the policy - const updatedPolicy = await db.policy.update({ - where: { id }, - data: updatePayload, - select: { - id: true, - name: true, - description: true, - status: true, - content: true, - frequency: true, - department: true, - isRequiredToSign: true, - signedBy: true, - reviewDate: true, - isArchived: true, - createdAt: true, - updatedAt: true, - lastArchivedAt: true, - lastPublishedAt: true, - organizationId: true, - assigneeId: true, - approverId: true, - policyTemplateId: true, - }, + // All reads and writes in one transaction to prevent concurrent publish bypass + const updatedPolicy = await db.$transaction(async (tx) => { + // Check existence and status inside the transaction + const existingPolicy = await tx.policy.findFirst({ + where: { id, organizationId }, + select: { id: true, status: true }, + }); + + if (!existingPolicy) { + throw new NotFoundException(`Policy with ID ${id} not found`); + } + + // Cannot update content unless policy is in draft status + // This covers both 'published' and 'needs_review' states + if (contentValue && existingPolicy.status !== 'draft') { + throw new BadRequestException( + 'Cannot update content of a published policy. Create a new version to make changes.', + ); + } + + const policy = await tx.policy.update({ + where: { id }, + data: updatePayload, + select: { + id: true, + name: true, + description: true, + status: true, + content: true, + frequency: true, + department: true, + isRequiredToSign: true, + signedBy: true, + reviewDate: true, + isArchived: true, + createdAt: true, + updatedAt: true, + lastArchivedAt: true, + lastPublishedAt: true, + organizationId: true, + assigneeId: true, + approverId: true, + policyTemplateId: true, + currentVersionId: true, + }, + }); + + // Keep current version content in sync with policy content + if (contentValue && policy.currentVersionId) { + await tx.policyVersion.update({ + where: { id: policy.currentVersionId }, + data: { content: contentValue }, + }); + } + + return policy; }); this.logger.log(`Updated policy: ${updatedPolicy.name} (${id})`); @@ -361,6 +383,48 @@ export class PoliciesService { } } + async getVersionById( + policyId: string, + versionId: string, + organizationId: string, + ) { + const policy = await db.policy.findFirst({ + where: { id: policyId, organizationId }, + select: { id: true, currentVersionId: true, pendingVersionId: true }, + }); + + if (!policy) { + throw new NotFoundException(`Policy with ID ${policyId} not found`); + } + + const version = await db.policyVersion.findUnique({ + where: { id: versionId }, + include: { + publishedBy: { + include: { + user: { + select: { + id: true, + name: true, + image: true, + }, + }, + }, + }, + }, + }); + + if (!version || version.policyId !== policyId) { + throw new NotFoundException('Version not found'); + } + + return { + version, + currentVersionId: policy.currentVersionId, + pendingVersionId: policy.pendingVersionId, + }; + } + async getVersions(policyId: string, organizationId: string) { const policy = await db.policy.findFirst({ where: { id: policyId, organizationId }, @@ -530,6 +594,7 @@ export class PoliciesService { select: { id: true, organizationId: true, + status: true, currentVersionId: true, pendingVersionId: true, }, @@ -545,7 +610,12 @@ export class PoliciesService { throw new NotFoundException('Version not found'); } - if (version.id === version.policy.currentVersionId) { + // Cannot edit the current version unless the policy is in draft status + // This covers both 'published' and 'needs_review' states + if ( + version.id === version.policy.currentVersionId && + version.policy.status !== 'draft' + ) { throw new BadRequestException( 'Cannot edit the published version. Create a new version to make changes.', ); diff --git a/apps/api/src/policies/schemas/version-operations.ts b/apps/api/src/policies/schemas/version-operations.ts index a8c52e629..29273615f 100644 --- a/apps/api/src/policies/schemas/version-operations.ts +++ b/apps/api/src/policies/schemas/version-operations.ts @@ -6,6 +6,11 @@ export const VERSION_OPERATIONS: Record = { description: 'Returns all versions for a policy in descending order. Supports both API key authentication and session authentication.', }, + getPolicyVersionById: { + summary: 'Get policy version by ID', + description: + 'Returns a single policy version by its ID, including content and metadata.', + }, createPolicyVersion: { summary: 'Create policy version', description: diff --git a/apps/api/src/policies/schemas/version-responses.ts b/apps/api/src/policies/schemas/version-responses.ts index 18b21bd3f..8281071ae 100644 --- a/apps/api/src/policies/schemas/version-responses.ts +++ b/apps/api/src/policies/schemas/version-responses.ts @@ -43,6 +43,30 @@ const BAD_REQUEST_RESPONSE: ApiResponseOptions = { }, }; +export const GET_POLICY_VERSION_BY_ID_RESPONSES: Record< + string, + ApiResponseOptions +> = { + 200: { + status: 200, + description: 'Policy version retrieved successfully', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + version: { type: 'object' }, + currentVersionId: { type: 'string', nullable: true }, + pendingVersionId: { type: 'string', nullable: true }, + }, + }, + }, + }, + }, + 401: UNAUTHORIZED_RESPONSE, + 404: NOT_FOUND_RESPONSE, +}; + export const GET_POLICY_VERSIONS_RESPONSES: Record = { 200: { diff --git a/apps/app/.env.example b/apps/app/.env.example index 680205be6..9b08a9bb2 100644 --- a/apps/app/.env.example +++ b/apps/app/.env.example @@ -44,7 +44,7 @@ APP_AWS_ENDPOINT="" # optional for using services like MinIO REVALIDATION_SECRET="" # Revalidate server side, generate something random GROQ_API_KEY="" # For the AI chat, on dashboard -NEXT_PUBLIC_PORTAL_URL="http://localhost:3001" +NEXT_PUBLIC_PORTAL_URL="http://localhost:3002" ANTHROPIC_API_KEY="" # Optional, For more options with models BETTER_AUTH_URL=http://localhost:3000 # For auth diff --git a/apps/app/package.json b/apps/app/package.json index 7855ec0a3..19c7b1f2b 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -66,6 +66,7 @@ "@vercel/analytics": "^1.5.0", "@vercel/sandbox": "^0.0.21", "@vercel/sdk": "^1.7.1", + "@xyflow/react": "^12.10.0", "ai": "^5.0.108", "ai-elements": "^1.6.1", "axios": "^1.9.0", diff --git a/apps/app/src/actions/policies/update-version-content.ts b/apps/app/src/actions/policies/update-version-content.ts index cccf660e5..eb43a6973 100644 --- a/apps/app/src/actions/policies/update-version-content.ts +++ b/apps/app/src/actions/policies/update-version-content.ts @@ -105,8 +105,9 @@ export const updateVersionContentAction = authActionClient return { success: false, error: 'Version does not belong to this policy' }; } - // Cannot edit published version (only if the policy is actually published) - if (version.id === version.policy.currentVersionId && version.policy.status === PolicyStatus.published) { + // Cannot edit the current version unless the policy is in draft status + // This covers both 'published' and 'needs_review' states + if (version.id === version.policy.currentVersionId && version.policy.status !== PolicyStatus.draft) { return { success: false, error: 'Cannot edit the published version. Create a new version to make changes.', diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/update-employee.ts b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/update-employee.ts index d902f73ac..43170355d 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/update-employee.ts +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/update-employee.ts @@ -1,6 +1,7 @@ 'use server'; import { authActionClient } from '@/actions/safe-action'; +import { removeMemberFromOrgChart } from '@/lib/org-chart'; import type { Departments } from '@db'; import { db, Prisma } from '@db'; import { revalidatePath } from 'next/cache'; @@ -14,6 +15,7 @@ const schema = z.object({ department: z.string().optional(), isActive: z.boolean().optional(), createdAt: z.date().optional(), + jobTitle: z.string().optional(), }); export const updateEmployee = authActionClient @@ -26,7 +28,7 @@ export const updateEmployee = authActionClient }, }) .action(async ({ parsedInput, ctx }) => { - const { employeeId, name, email, department, isActive, createdAt } = parsedInput; + const { employeeId, name, email, department, isActive, createdAt, jobTitle } = parsedInput; const organizationId = ctx.session.activeOrganizationId; if (!organizationId) { @@ -81,6 +83,7 @@ export const updateEmployee = authActionClient department?: Departments; isActive?: boolean; createdAt?: Date; + jobTitle?: string; } = {}; const userUpdateData: { name?: string; email?: string } = {}; @@ -93,6 +96,9 @@ export const updateEmployee = authActionClient if (createdAt !== undefined && createdAt.toISOString() !== member.createdAt.toISOString()) { memberUpdateData.createdAt = createdAt; } + if (jobTitle !== undefined && jobTitle !== member.jobTitle) { + memberUpdateData.jobTitle = jobTitle; + } if (name !== undefined && name !== member.user.name) { userUpdateData.name = name; } @@ -135,6 +141,11 @@ export const updateEmployee = authActionClient } }); + // If the member was just deactivated, remove them from the org chart + if (memberUpdateData.isActive === false) { + await removeMemberFromOrgChart(organizationId, employeeId); + } + revalidatePath(`/${organizationId}/people/${employeeId}`); revalidatePath(`/${organizationId}/people`); 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 d9a16b72e..2bfb92a3f 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 @@ -1,32 +1,43 @@ 'use client'; -import { Button } from '@comp/ui/button'; -import { Form } from '@comp/ui/form'; +import { Popover, PopoverContent, PopoverTrigger } from '@comp/ui/popover'; import type { Departments, Member, User } from '@db'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { Section, Stack } from '@trycompai/design-system'; -import { Save } from 'lucide-react'; +import { + Button, + Calendar, + Grid, + HStack, + Input, + Label, + Section, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Stack, +} from '@trycompai/design-system'; +import { ChevronDown } from '@trycompai/design-system/icons'; +import { format } from 'date-fns'; import { useAction } from 'next-safe-action/hooks'; -import { useForm } from 'react-hook-form'; +import { useMemo, useState } from 'react'; import { toast } from 'sonner'; -import { z } from 'zod'; import { updateEmployee } from '../actions/update-employee'; -import { Department } from './Fields/Department'; -import { Email } from './Fields/Email'; -import { JoinDate } from './Fields/JoinDate'; -import { Name } from './Fields/Name'; -import { Status } from './Fields/Status'; - -// Define form schema with Zod -const employeeFormSchema = z.object({ - name: z.string().min(1, 'Name is required'), - email: z.string().email('Invalid email address'), - department: z.enum(['admin', 'gov', 'hr', 'it', 'itsm', 'qms', 'none'] as const), - status: z.enum(['active', 'inactive'] as const), - createdAt: z.date(), -}); - -export type EmployeeFormValues = z.infer; + +const DEPARTMENTS: { value: string; label: string }[] = [ + { value: 'admin', label: 'Admin' }, + { value: 'gov', label: 'Governance' }, + { value: 'hr', label: 'HR' }, + { value: 'it', label: 'IT' }, + { value: 'itsm', label: 'IT Service Management' }, + { value: 'qms', label: 'Quality Management' }, + { value: 'none', label: 'None' }, +]; + +const STATUS_OPTIONS = [ + { value: 'active', label: 'Active' }, + { value: 'inactive', label: 'Inactive' }, +]; export const EmployeeDetails = ({ employee, @@ -37,17 +48,12 @@ export const EmployeeDetails = ({ }; canEdit: boolean; }) => { - const form = useForm({ - resolver: zodResolver(employeeFormSchema), - defaultValues: { - name: employee.user.name ?? '', - email: employee.user.email ?? '', - department: employee.department as Departments, - status: employee.isActive ? 'active' : 'inactive', - createdAt: new Date(employee.createdAt), - }, - mode: 'onChange', - }); + const [name, setName] = useState(employee.user.name ?? ''); + const [jobTitle, setJobTitle] = useState(employee.jobTitle ?? ''); + const [department, setDepartment] = useState(employee.department ?? 'none'); + const [status, setStatus] = useState(employee.isActive ? 'active' : 'inactive'); + const [joinDate, setJoinDate] = useState(new Date(employee.createdAt)); + const [datePickerOpen, setDatePickerOpen] = useState(false); const { execute, status: actionStatus } = useAction(updateEmployee, { onSuccess: (res) => { @@ -62,75 +68,188 @@ export const EmployeeDetails = ({ }, }); - const onSubmit = async (values: EmployeeFormValues) => { - // Prepare update data + const hasChanges = useMemo(() => { + const nameChanged = name !== (employee.user.name ?? ''); + const jobTitleChanged = jobTitle !== (employee.jobTitle ?? ''); + const departmentChanged = department !== (employee.department ?? 'none'); + const statusChanged = status !== (employee.isActive ? 'active' : 'inactive'); + const dateChanged = joinDate.toISOString() !== new Date(employee.createdAt).toISOString(); + + return nameChanged || jobTitleChanged || departmentChanged || statusChanged || dateChanged; + }, [name, jobTitle, department, status, joinDate, employee]); + + const isLoading = actionStatus === 'executing'; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (!name.trim()) { + toast.error('Name is required'); + return; + } + const updateData: { employeeId: string; name?: string; - email?: string; department?: string; isActive?: boolean; createdAt?: Date; + jobTitle?: string; } = { employeeId: employee.id }; - // Only include changed fields - if (values.name !== employee.user.name) { - updateData.name = values.name; + if (name !== (employee.user.name ?? '')) { + updateData.name = name; } - if (values.email !== employee.user.email) { - updateData.email = values.email; + if (jobTitle !== (employee.jobTitle ?? '')) { + updateData.jobTitle = jobTitle; } - if (values.department !== employee.department) { - updateData.department = values.department; + if (department !== employee.department) { + updateData.department = department; } - if (values.createdAt && values.createdAt.toISOString() !== employee.createdAt.toISOString()) { - updateData.createdAt = values.createdAt; + if (joinDate.toISOString() !== new Date(employee.createdAt).toISOString()) { + updateData.createdAt = joinDate; } - const isActive = values.status === 'active'; + const isActive = status === 'active'; if (isActive !== employee.isActive) { updateData.isActive = isActive; } - // Execute the update only if there are changes if (Object.keys(updateData).length > 1) { - await execute(updateData); + execute(updateData); } else { - // No changes were made toast.info('No changes to save'); } }; return (
-
- - -
- - - - - -
-
- + + + date && setJoinDate(date)} + captionLayout="dropdown" + disabled={(date) => date > new Date()} + /> + + + + + + + + + +
); }; diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Department.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Department.tsx deleted file mode 100644 index c08004b8e..000000000 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Department.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@comp/ui/form'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@comp/ui/select'; -import type { Departments } from '@db'; -import type { Control } from 'react-hook-form'; -import type { EmployeeFormValues } from '../EmployeeDetails'; - -const DEPARTMENTS: { value: Departments; label: string }[] = [ - { value: 'admin', label: 'Admin' }, - { value: 'gov', label: 'Governance' }, - { value: 'hr', label: 'HR' }, - { value: 'it', label: 'IT' }, - { value: 'itsm', label: 'IT Service Management' }, - { value: 'qms', label: 'Quality Management' }, - { value: 'none', label: 'None' }, -]; - -export const Department = ({ - control, - disabled, -}: { - control: Control; - disabled: boolean; -}) => { - return ( - ( - - - Department - - - - - )} - /> - ); -}; diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Email.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Email.tsx deleted file mode 100644 index af31cdd29..000000000 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Email.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@comp/ui/form'; -import { Input } from '@comp/ui/input'; -import type { Control } from 'react-hook-form'; -import type { EmployeeFormValues } from '../EmployeeDetails'; - -export const Email = ({ - control, - disabled, -}: { - control: Control; - disabled: boolean; -}) => { - return ( - ( - - - EMAIL - - - - - - - )} - /> - ); -}; diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/JoinDate.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/JoinDate.tsx deleted file mode 100644 index da6f2c223..000000000 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/JoinDate.tsx +++ /dev/null @@ -1,76 +0,0 @@ -'use client'; - -import { Button } from '@comp/ui/button'; -import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@comp/ui/form'; -import { Popover, PopoverContent, PopoverTrigger } from '@comp/ui/popover'; -import { Calendar } from '@trycompai/design-system'; -import { format } from 'date-fns'; -import { ChevronDown } from 'lucide-react'; -import { useState } from 'react'; -import type { Control } from 'react-hook-form'; -import type { EmployeeFormValues } from '../EmployeeDetails'; - -export const JoinDate = ({ - control, - disabled, -}: { - control: Control; - disabled: boolean; -}) => { - const [open, setOpen] = useState(false); - - return ( - { - return ( - - - - - Join Date - - - - - - date && field.onChange(date)} - captionLayout="dropdown" - disabled={(date) => date > new Date()} - /> -
- -
-
-
-
- -
- ); - }} - /> - ); -}; diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Name.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Name.tsx deleted file mode 100644 index 38dc6c952..000000000 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Name.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@comp/ui/form'; -import { Input } from '@comp/ui/input'; -import type { Control } from 'react-hook-form'; -import type { EmployeeFormValues } from '../EmployeeDetails'; - -export const Name = ({ - control, - disabled, -}: { - control: Control; - disabled: boolean; -}) => { - return ( - ( - - - NAME - - - - - - - )} - /> - ); -}; diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Status.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Status.tsx deleted file mode 100644 index 7368291df..000000000 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Status.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import type { EmployeeStatusType } from '@/components/tables/people/employee-status'; -import { cn } from '@comp/ui/cn'; -import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@comp/ui/form'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@comp/ui/select'; -import type { Control } from 'react-hook-form'; -import type { EmployeeFormValues } from '../EmployeeDetails'; - -const STATUS_OPTIONS: { value: EmployeeStatusType; label: string }[] = [ - { value: 'active', label: 'Active' }, - { value: 'inactive', label: 'Inactive' }, -]; - -// Status color hex values for charts -export const EMPLOYEE_STATUS_HEX_COLORS: Record = { - inactive: 'var(--color-destructive)', - active: 'var(--color-primary)', -}; - -export const Status = ({ - control, - disabled, -}: { - control: Control; - disabled: boolean; -}) => { - return ( - ( - - - Status - - - - - )} - /> - ); -}; diff --git a/apps/app/src/app/(app)/[orgId]/people/all/actions/addEmployeeWithoutInvite.ts b/apps/app/src/app/(app)/[orgId]/people/all/actions/addEmployeeWithoutInvite.ts index 8d72f710b..68cbd7fc9 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/actions/addEmployeeWithoutInvite.ts +++ b/apps/app/src/app/(app)/[orgId]/people/all/actions/addEmployeeWithoutInvite.ts @@ -2,6 +2,7 @@ import { createTrainingVideoEntries } from '@/lib/db/employee'; import { auth } from '@/utils/auth'; +import { sendInviteMemberEmail } from '@comp/email'; import type { Role } from '@db'; import { db } from '@db'; import { headers } from 'next/headers'; @@ -48,6 +49,16 @@ export const addEmployeeWithoutInvite = async ({ } } + // Get organization name + const organization = await db.organization.findUnique({ + where: { id: organizationId }, + select: { name: true }, + }); + + if (!organization) { + throw new Error('Organization not found.'); + } + let userId = ''; const existingUser = await db.user.findFirst({ where: { @@ -112,7 +123,30 @@ export const addEmployeeWithoutInvite = async ({ await createTrainingVideoEntries(member.id); } - return { success: true, data: member }; + // Generate invite link + const inviteLink = `${process.env.NEXT_PUBLIC_PORTAL_URL}/${organizationId}`; + + // Send the invitation email (non-fatal: member is already created) + let emailSent = true; + let emailError: string | undefined; + try { + await sendInviteMemberEmail({ + inviteeEmail: email.toLowerCase(), + inviteLink, + organizationName: organization.name, + }); + } catch (emailErr) { + emailSent = false; + emailError = emailErr instanceof Error ? emailErr.message : 'Failed to send invite email'; + console.error('Invite email failed after member was added:', { email, organizationId, error: emailErr }); + } + + return { + success: true, + data: member, + emailSent, + ...(emailError && { emailError }), + }; } catch (error) { console.error('Error adding employee:', error); return { success: false, error: 'Failed to add employee' }; diff --git a/apps/app/src/app/(app)/[orgId]/people/all/actions/reactivateMember.ts b/apps/app/src/app/(app)/[orgId]/people/all/actions/reactivateMember.ts new file mode 100644 index 000000000..433fea0cb --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/all/actions/reactivateMember.ts @@ -0,0 +1,102 @@ +'use server'; + +import { db } from '@db'; +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; +import { authActionClient } from '@/actions/safe-action'; +import type { ActionResponse } from '@/actions/types'; + +const reactivateMemberSchema = z.object({ + memberId: z.string(), +}); + +export const reactivateMember = authActionClient + .metadata({ + name: 'reactivate-member', + track: { + event: 'reactivate_member', + channel: 'organization', + }, + }) + .inputSchema(reactivateMemberSchema) + .action(async ({ parsedInput, ctx }): Promise> => { + if (!ctx.session.activeOrganizationId) { + return { + success: false, + error: 'User does not have an organization', + }; + } + + const { memberId } = parsedInput; + + try { + // Check if user has admin permissions + const currentUserMember = await db.member.findFirst({ + where: { + organizationId: ctx.session.activeOrganizationId, + userId: ctx.user.id, + deactivated: false, + }, + }); + + if ( + !currentUserMember || + (!currentUserMember.role.includes('admin') && !currentUserMember.role.includes('owner')) + ) { + return { + success: false, + error: "You don't have permission to reactivate members", + }; + } + + // Check if the target member exists and is deactivated + const targetMember = await db.member.findFirst({ + where: { + id: memberId, + organizationId: ctx.session.activeOrganizationId, + }, + include: { + user: true, + }, + }); + + if (!targetMember) { + return { + success: false, + error: 'Member not found in this organization', + }; + } + + if (!targetMember.deactivated && targetMember.isActive) { + return { + success: false, + error: 'Member is already active', + }; + } + + // Reactivate the member + await db.member.update({ + where: { + id: memberId, + }, + data: { + deactivated: false, + isActive: true, + }, + }); + + revalidatePath(`/${ctx.session.activeOrganizationId}/people`); + revalidatePath(`/${ctx.session.activeOrganizationId}/people/${memberId}`); + + return { + success: true, + data: { reactivated: true }, + }; + } catch (error) { + console.error('Error reactivating member:', error); + return { + success: false, + error: 'Failed to reactivate member', + }; + } + }); diff --git a/apps/app/src/app/(app)/[orgId]/people/all/actions/removeMember.ts b/apps/app/src/app/(app)/[orgId]/people/all/actions/removeMember.ts index 734f1ca62..62ed85869 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/actions/removeMember.ts +++ b/apps/app/src/app/(app)/[orgId]/people/all/actions/removeMember.ts @@ -12,6 +12,7 @@ import { type UnassignedItem, } from '@comp/email'; import { getFleetInstance } from '@/lib/fleet'; +import { removeMemberFromOrgChart } from '@/lib/org-chart'; const removeMemberSchema = z.object({ memberId: z.string(), @@ -236,6 +237,9 @@ export const removeMember = authActionClient }), ]); + // Remove the member from the org chart (if present) + await removeMemberFromOrgChart(ctx.session.activeOrganizationId, memberId); + // Mark the member as deactivated instead of deleting await db.member.update({ where: { diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/InviteMembersModal.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/InviteMembersModal.tsx index 97443acc5..80e1c3f81 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/InviteMembersModal.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/InviteMembersModal.tsx @@ -162,6 +162,7 @@ export function InviteMembersModal({ // Process invitations let successCount = 0; const failedInvites: { email: string; error: string }[] = []; + const emailFailedEmails: string[] = []; // Process each invitation sequentially for (const invite of values.manualInvites) { @@ -170,11 +171,22 @@ export function InviteMembersModal({ (invite.roles.includes('employee') || invite.roles.includes('contractor')); try { if (hasEmployeeRoleAndNoAdmin) { - await addEmployeeWithoutInvite({ + const result = await addEmployeeWithoutInvite({ organizationId, email: invite.email.toLowerCase(), roles: invite.roles, }); + if (!result.success) { + failedInvites.push({ + email: invite.email, + error: result.error ?? 'Failed to add employee', + }); + } else { + if ('emailSent' in result && result.emailSent === false) { + emailFailedEmails.push(invite.email); + } + successCount++; + } } else { // Check member status and reactivate if needed const memberStatus = await checkMemberStatus({ @@ -197,8 +209,8 @@ export function InviteMembersModal({ roles: invite.roles, }); } + successCount++; } - successCount++; } catch (error) { console.error(`Failed to invite ${invite.email}:`, error); failedInvites.push({ @@ -226,6 +238,12 @@ export function InviteMembersModal({ `Failed to invite ${failedInvites.length} member(s): ${failedInvites.map((f) => f.email).join(', ')}`, ); } + + if (emailFailedEmails.length > 0) { + toast.warning( + `${emailFailedEmails.length} member(s) added but invite email could not be sent: ${emailFailedEmails.join(', ')}. You can resend from the team page.`, + ); + } } else if (values.mode === 'csv') { // Handle CSV file uploads console.log('Processing CSV mode'); @@ -305,6 +323,7 @@ export function InviteMembersModal({ // Track results let successCount = 0; const failedInvites: { email: string; error: string }[] = []; + const emailFailedEmails: string[] = []; // Process each row for (const row of dataRows) { @@ -331,7 +350,7 @@ export function InviteMembersModal({ } // Validate role(s) - split by pipe for multiple roles - const roles = roleValue.split('|').map((r) => r.trim()); + const roles = roleValue.split('|').map((r) => r.trim().toLowerCase()); const validRoles = roles.filter((role) => isInviteRole(role, normalizedAllowedRoles)); if (validRoles.length === 0) { @@ -348,11 +367,22 @@ export function InviteMembersModal({ !validRoles.includes('admin'); try { if (hasEmployeeRoleAndNoAdmin) { - await addEmployeeWithoutInvite({ + const result = await addEmployeeWithoutInvite({ organizationId, email: email.toLowerCase(), roles: validRoles, }); + if (!result.success) { + failedInvites.push({ + email, + error: result.error ?? 'Failed to add employee', + }); + } else { + if ('emailSent' in result && result.emailSent === false) { + emailFailedEmails.push(email); + } + successCount++; + } } else { // Check member status and reactivate if needed const memberStatus = await checkMemberStatus({ @@ -375,8 +405,8 @@ export function InviteMembersModal({ roles: validRoles, }); } + successCount++; } - successCount++; } catch (error) { console.error(`Failed to invite ${email}:`, error); failedInvites.push({ @@ -404,6 +434,12 @@ export function InviteMembersModal({ `Failed to invite ${failedInvites.length} member(s): ${failedInvites.map((f) => f.email).join(', ')}`, ); } + + if (emailFailedEmails.length > 0) { + toast.warning( + `${emailFailedEmails.length} member(s) added but invite email could not be sent: ${emailFailedEmails.join(', ')}. You can resend from the team page.`, + ); + } } catch (csvError) { console.error('Error parsing CSV:', csvError); toast.error('Failed to parse CSV file. Please check the format.'); 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 3c700371b..6f94b1b73 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 @@ -16,7 +16,7 @@ import { TableRow, Text, } from '@trycompai/design-system'; -import { Edit, OverflowMenuVertical, TrashCan } from '@trycompai/design-system/icons'; +import { Checkmark, Edit, OverflowMenuVertical, TrashCan } from '@trycompai/design-system/icons'; import { Button } from '@comp/ui/button'; import { Dialog, @@ -45,6 +45,7 @@ interface MemberRowProps { onRemove: (memberId: string) => void; onRemoveDevice: (memberId: string) => void; onUpdateRole: (memberId: string, roles: Role[]) => void; + onReactivate: (memberId: string) => void; canEdit: boolean; isCurrentUserOwner: boolean; } @@ -93,6 +94,7 @@ export function MemberRow({ onRemove, onRemoveDevice, onUpdateRole, + onReactivate, canEdit, isCurrentUserOwner, }: MemberRowProps) { @@ -106,6 +108,7 @@ export function MemberRow({ const [isUpdating, setIsUpdating] = useState(false); const [isRemoving, setIsRemoving] = useState(false); const [isRemovingDevice, setIsRemovingDevice] = useState(false); + const [isReactivating, setIsReactivating] = useState(false); const memberName = member.user.name || member.user.email || 'Member'; const memberEmail = member.user.email || ''; @@ -151,6 +154,16 @@ export function MemberRow({ } }; + const handleReactivateClick = async () => { + setDropdownOpen(false); + setIsReactivating(true); + try { + await onReactivate(memberId); + } finally { + setIsReactivating(false); + } + }; + const handleRemoveDeviceClick = async () => { try { setIsRemoveDeviceAlertOpen(false); @@ -211,48 +224,55 @@ export function MemberRow({ {/* ACTIONS */} - {!isDeactivated && ( -
- - - - - - {canEdit && ( - - - Edit Roles - - )} - {member.fleetDmLabelId && isCurrentUserOwner && ( - { - setDropdownOpen(false); - setIsRemoveDeviceAlertOpen(true); - }} - > - - Remove Device - - )} - {canRemove && ( - { - setDropdownOpen(false); - setIsRemoveAlertOpen(true); - }} - > - - Remove Member - - )} - - -
- )} +
+ + + + + + {isDeactivated && canEdit && ( + + + {isReactivating ? 'Reinstating...' : 'Reinstate Member'} + + )} + {!isDeactivated && canEdit && ( + + + Edit Roles + + )} + {!isDeactivated && member.fleetDmLabelId && isCurrentUserOwner && ( + { + setDropdownOpen(false); + setIsRemoveDeviceAlertOpen(true); + }} + > + + Remove Device + + )} + {!isDeactivated && canRemove && ( + { + setDropdownOpen(false); + setIsRemoveAlertOpen(true); + }} + > + + Remove Member + + )} + + +
diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembers.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembers.tsx index c04899ab9..dc45fc89c 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembers.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembers.tsx @@ -4,6 +4,7 @@ import { auth } from '@/utils/auth'; import type { Invitation, Member, User } from '@db'; import { db } from '@db'; import { headers } from 'next/headers'; +import { reactivateMember } from '../actions/reactivateMember'; import { removeMember } from '../actions/removeMember'; import { revokeInvitation } from '../actions/revokeInvitation'; import { getEmployeeSyncConnections } from '../data/queries'; @@ -80,6 +81,7 @@ export async function TeamMembers(props: TeamMembersProps) { data={data} organizationId={organizationId ?? ''} removeMemberAction={removeMember} + reactivateMemberAction={reactivateMember} revokeInvitationAction={revokeInvitation} canManageMembers={canManageMembers} canInviteUsers={canInviteUsers} 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 001fa1080..767e43913 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 @@ -37,6 +37,7 @@ import { PendingInvitationRow } from './PendingInvitationRow'; import type { MemberWithUser, TeamMembersData } from './TeamMembers'; // Import the server actions themselves to get their types +import type { reactivateMember } from '../actions/reactivateMember'; import type { removeMember } from '../actions/removeMember'; import type { revokeInvitation } from '../actions/revokeInvitation'; @@ -48,6 +49,7 @@ interface TeamMembersClientProps { data: TeamMembersData; organizationId: string; removeMemberAction: typeof removeMember; + reactivateMemberAction: typeof reactivateMember; revokeInvitationAction: typeof revokeInvitation; canManageMembers: boolean; canInviteUsers: boolean; @@ -72,6 +74,7 @@ export function TeamMembersClient({ data, organizationId, removeMemberAction, + reactivateMemberAction, revokeInvitationAction, canManageMembers, canInviteUsers, @@ -225,6 +228,18 @@ export function TeamMembersClient({ } }; + const handleReactivateMember = async (memberId: string) => { + const result = await reactivateMemberAction({ memberId }); + if (result?.data?.success) { + toast.success('Member has been reinstated'); + router.refresh(); + } else { + const errorMessage = result?.serverError || 'Failed to reinstate member'; + console.error('Reactivate Member Error:', errorMessage); + toast.error(errorMessage); + } + }; + const handleRemoveDevice = async (memberId: string) => { await unlinkDevice(memberId); toast.success('Device unlinked successfully'); @@ -503,6 +518,7 @@ export function TeamMembersClient({ onRemove={handleRemoveMember} onRemoveDevice={handleRemoveDevice} onUpdateRole={handleUpdateRole} + onReactivate={handleReactivateMember} canEdit={canManageMembers} isCurrentUserOwner={isCurrentUserOwner} /> 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 8af9e307a..c64863c77 100644 --- a/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx @@ -18,6 +18,7 @@ interface PeoplePageTabsProps { peopleContent: ReactNode; employeeTasksContent: ReactNode | null; devicesContent: ReactNode; + orgChartContent: ReactNode; showEmployeeTasks: boolean; canInviteUsers: boolean; canManageMembers: boolean; @@ -28,6 +29,7 @@ export function PeoplePageTabs({ peopleContent, employeeTasksContent, devicesContent, + orgChartContent, showEmployeeTasks, canInviteUsers, canManageMembers, @@ -44,10 +46,9 @@ export function PeoplePageTabs({ tabs={ People - {showEmployeeTasks && ( - Employee Tasks - )} - Employee Devices + {showEmployeeTasks && Tasks} + Devices + Chart } actions={ @@ -67,6 +68,7 @@ export function PeoplePageTabs({ {employeeTasksContent} )} {devicesContent} + {orgChartContent} ; + } + + // Uploaded image mode + if (chartData.type === 'uploaded' && chartData.signedImageUrl) { + return ( + + ); + } + + // Uploaded chart but image could not be loaded (e.g. S3 unavailable) + if (chartData.type === 'uploaded') { + return ( +
+
+

+ The uploaded org chart image could not be loaded. +

+

+ Please try again later or re-upload the image. +

+
+
+ ); + } + + // Interactive mode + return ( + + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartEditor.tsx b/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartEditor.tsx new file mode 100644 index 000000000..077c2970a --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartEditor.tsx @@ -0,0 +1,341 @@ +'use client'; + +import { useApi } from '@/hooks/use-api'; +import { Button } from '@trycompai/design-system'; +import { Add, Close, Edit, Locked, Save, Unlocked } from '@trycompai/design-system/icons'; +import { + Background, + BackgroundVariant, + Controls, + ReactFlow, + addEdge, + useEdgesState, + useNodesState, + type Connection, + type Edge, + type Node, + type ReactFlowInstance, +} from '@xyflow/react'; +import '@xyflow/react/dist/style.css'; +import { formatDistanceToNow } from 'date-fns'; +import { useRouter } from 'next/navigation'; +import { useCallback, useMemo, useRef, useState } from 'react'; +import { toast } from 'sonner'; +import type { OrgChartMember } from '../types'; +import { OrgChartNode, type OrgChartNodeData } from './OrgChartNode'; +import { PeopleSidebar } from './PeopleSidebar'; + +interface OrgChartEditorProps { + initialNodes: Node[]; + initialEdges: Edge[]; + members: OrgChartMember[]; + updatedAt: string | null; +} + +export function OrgChartEditor({ + initialNodes, + initialEdges, + members, + updatedAt, +}: OrgChartEditorProps) { + const api = useApi(); + const router = useRouter(); + const reactFlowWrapper = useRef(null); + const [reactFlowInstance, setReactFlowInstance] = useState(null); + + const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); + + const [isLocked, setIsLocked] = useState(initialNodes.length > 0); + const [isSaving, setIsSaving] = useState(false); + const [lastSavedAt, setLastSavedAt] = useState(updatedAt); + const [showSidebar, setShowSidebar] = useState(false); + + // Store the last saved state for cancel/revert + const savedStateRef = useRef({ nodes: initialNodes, edges: initialEdges }); + + const nodeTypes = useMemo(() => ({ orgChartNode: OrgChartNode }), []); + + // Compute which member IDs are already on the chart + const placedMemberIds = useMemo(() => { + const ids = new Set(); + for (const node of nodes) { + const data = node.data as OrgChartNodeData | undefined; + if (data?.memberId) { + ids.add(data.memberId); + } + } + return ids; + }, [nodes]); + + // Inject isLocked and onTitleChange into every node's data for rendering + const nodesWithCallbacks = useMemo(() => { + return nodes.map((node) => ({ + ...node, + data: { + ...(node.data ?? {}), + isLocked, + onTitleChange: (newTitle: string) => { + handleTitleChange(node.id, newTitle); + }, + }, + })); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [nodes, isLocked]); + + const onConnect = useCallback( + (params: Connection) => { + if (isLocked) return; + setEdges((eds) => addEdge({ ...params, type: 'smoothstep' }, eds)); + }, + [isLocked, setEdges], + ); + + /** + * Serialize nodes/edges to plain JSON-safe objects before saving. + * React Flow nodes may carry internal/non-serializable properties; + * we strip everything except the fields we actually need. + */ + const serializeForSave = () => { + const cleanNodes = nodes.map((n) => { + const data = (n.data ?? {}) as Record; + return { + id: n.id, + type: n.type, + position: { x: n.position.x, y: n.position.y }, + data: { + name: data.name ?? '', + title: data.title ?? '', + memberId: data.memberId ?? undefined, + }, + }; + }); + + const cleanEdges = edges.map((e) => ({ + id: e.id, + source: e.source, + target: e.target, + type: e.type ?? 'smoothstep', + })); + + return { nodes: cleanNodes, edges: cleanEdges }; + }; + + const handleSave = async () => { + setIsSaving(true); + try { + const payload = serializeForSave(); + const response = await api.put<{ updatedAt: string }>('/v1/org-chart', payload); + + if (response.error) { + toast.error('Failed to save org chart'); + return; + } + + // Batch-persist job title changes to member records + const savedNodeMap = new Map(); + for (const n of savedStateRef.current.nodes) { + const d = n.data as OrgChartNodeData | undefined; + if (d?.memberId) savedNodeMap.set(d.memberId, d); + } + for (const n of nodes) { + const d = n.data as OrgChartNodeData | undefined; + if (d?.memberId) { + const prev = savedNodeMap.get(d.memberId); + const newTitle = (d.title ?? '').trim(); + const oldTitle = (prev?.title ?? '').trim(); + if (newTitle !== oldTitle) { + api.patch(`/v1/people/${d.memberId}`, { jobTitle: newTitle }).catch(() => { + // Non-critical: chart data already persisted with the title + }); + } + } + } + + savedStateRef.current = { nodes: [...nodes], edges: [...edges] }; + setIsLocked(true); + setShowSidebar(false); + if (response.data?.updatedAt) { + setLastSavedAt(response.data.updatedAt); + } else { + setLastSavedAt(new Date().toISOString()); + } + toast.success('Org chart saved'); + router.refresh(); + } catch { + toast.error('Failed to save org chart'); + } finally { + setIsSaving(false); + } + }; + + const handleCancel = () => { + setNodes(savedStateRef.current.nodes); + setEdges(savedStateRef.current.edges); + setIsLocked(true); + setShowSidebar(false); + }; + + const handleUnlock = () => { + setIsLocked(false); + }; + + const handleAddPerson = (person: { name: string; title: string; memberId?: string }) => { + const position = reactFlowInstance + ? reactFlowInstance.screenToFlowPosition({ + x: window.innerWidth / 2, + y: window.innerHeight / 3, + }) + : { x: 250 + Math.random() * 200, y: 100 + Math.random() * 200 }; + + const newNode: Node = { + id: `node-${Date.now()}`, + type: 'orgChartNode', + position, + data: { + name: person.name, + title: person.title, + memberId: person.memberId, + }, + }; + + setNodes((nds) => [...nds, newNode]); + }; + + const handleTitleChange = (nodeId: string, newTitle: string) => { + setNodes((nds) => + nds.map((n) => (n.id === nodeId ? { ...n, data: { ...n.data, title: newTitle } } : n)), + ); + }; + + const handleDeleteSelected = () => { + const selectedNodeIds = nodes.filter((n) => n.selected).map((n) => n.id); + setNodes((nds) => nds.filter((node) => !node.selected)); + setEdges((eds) => + eds.filter( + (edge) => + !edge.selected && + !selectedNodeIds.includes(edge.source) && + !selectedNodeIds.includes(edge.target), + ), + ); + }; + + const hasSelectedElements = nodes.some((n) => n.selected) || edges.some((e) => e.selected); + + const formattedLastSaved = lastSavedAt + ? `Saved ${formatDistanceToNow(new Date(lastSavedAt), { addSuffix: true })}` + : 'Not yet saved'; + + return ( +
+ {/* Toolbar */} +
+
+ {isLocked ? ( + + ) : ( + + )} + {formattedLastSaved} +
+
+ {isLocked ? ( + + ) : ( + <> + {/* Mobile: toggle people sidebar */} +
+ +
+ {hasSelectedElements && ( + + )} + + + + )} +
+
+ + {/* Canvas area with optional sidebar */} +
+ {/* Desktop: always show sidebar when unlocked */} + {!isLocked && ( +
+ +
+ )} + + {/* Mobile: overlay sidebar when toggled */} + {!isLocked && showSidebar && ( +
+ { + handleAddPerson(person); + setShowSidebar(false); + }} + placedMemberIds={placedMemberIds} + /> +
+ )} + +
+ + + + +
+
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartEmptyState.tsx b/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartEmptyState.tsx new file mode 100644 index 000000000..e677a8726 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartEmptyState.tsx @@ -0,0 +1,102 @@ +'use client'; + +import { useState } from 'react'; +import { Button } from '@trycompai/design-system'; +import { Add, Upload } from '@trycompai/design-system/icons'; +import { OrgChartEditor } from './OrgChartEditor'; +import { UploadOrgChartDialog } from './UploadOrgChartDialog'; +import type { OrgChartMember } from '../types'; + +interface OrgChartEmptyStateProps { + members: OrgChartMember[]; +} + +export function OrgChartEmptyState({ members }: OrgChartEmptyStateProps) { + const [mode, setMode] = useState<'empty' | 'create' | 'upload'>('empty'); + + if (mode === 'create') { + return ( + + ); + } + + if (mode === 'upload') { + return setMode('empty')} />; + } + + return ( +
+
+
+ + + + + + +
+

+ No Org Chart Yet +

+

+ Create an interactive organization chart or upload an existing one to + use as evidence for auditors. +

+
+
+ + +
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartImageView.tsx b/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartImageView.tsx new file mode 100644 index 000000000..6eeed4d7f --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartImageView.tsx @@ -0,0 +1,85 @@ +'use client'; + +import { useState } from 'react'; +import { Button } from '@trycompai/design-system'; +import { TrashCan, Upload } from '@trycompai/design-system/icons'; +import { useApi } from '@/hooks/use-api'; +import { toast } from 'sonner'; +import { useRouter } from 'next/navigation'; +import { UploadOrgChartDialog } from './UploadOrgChartDialog'; + +interface OrgChartImageViewProps { + imageUrl: string; + chartName: string; +} + +export function OrgChartImageView({ + imageUrl, + chartName, +}: OrgChartImageViewProps) { + const api = useApi(); + const router = useRouter(); + const [isDeleting, setIsDeleting] = useState(false); + const [showReplace, setShowReplace] = useState(false); + + const handleDelete = async () => { + setIsDeleting(true); + try { + const response = await api.delete('/v1/org-chart'); + + if (response.error) { + toast.error('Failed to delete org chart'); + return; + } + + toast.success('Org chart deleted'); + router.refresh(); + } catch { + toast.error('Failed to delete org chart'); + } finally { + setIsDeleting(false); + } + }; + + if (showReplace) { + return setShowReplace(false)} />; + } + + return ( +
+ {/* Toolbar */} +
+ {chartName} +
+ + +
+
+ + {/* Image */} +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {chartName} +
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartNode.tsx b/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartNode.tsx new file mode 100644 index 000000000..0d11dd49f --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartNode.tsx @@ -0,0 +1,132 @@ +'use client'; + +import { useState, useRef, useEffect, useCallback } from 'react'; +import { Handle, Position, type NodeProps } from '@xyflow/react'; + +export interface OrgChartNodeData { + name: string; + title?: string; + memberId?: string; + isLocked?: boolean; + onTitleChange?: (newTitle: string) => void; + [key: string]: unknown; +} + +export function OrgChartNode({ + data, + selected, +}: NodeProps & { data: OrgChartNodeData }) { + const [isEditingTitle, setIsEditingTitle] = useState(false); + const [editValue, setEditValue] = useState(data.title || ''); + const inputRef = useRef(null); + + const isLocked = data.isLocked ?? true; + + useEffect(() => { + if (isEditingTitle && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [isEditingTitle]); + + // Keep editValue in sync with data.title when not editing + useEffect(() => { + if (!isEditingTitle) { + setEditValue(data.title || ''); + } + }, [data.title, isEditingTitle]); + + const commitTitle = useCallback(() => { + setIsEditingTitle(false); + const trimmed = editValue.trim(); + if (trimmed !== (data.title || '') && data.onTitleChange) { + data.onTitleChange(trimmed); + } + }, [editValue, data]); + + const initials = data.name + .split(' ') + .map((n: string) => n[0]) + .join('') + .toUpperCase() + .slice(0, 2); + + return ( +
+ +
+
+ {initials} +
+
+ + {data.name} + + {isEditingTitle ? ( + setEditValue(e.target.value)} + onBlur={commitTitle} + onKeyDown={(e) => { + if (e.key === 'Enter') commitTitle(); + if (e.key === 'Escape') { + setEditValue(data.title || ''); + setIsEditingTitle(false); + } + }} + className="w-full rounded border border-border bg-background px-1 py-0.5 text-xs text-foreground focus:border-primary focus:outline-none" + placeholder="e.g. Engineering Manager" + /> + ) : data.title ? ( + { + if (!isLocked) { + e.stopPropagation(); + setIsEditingTitle(true); + } + }} + title={!isLocked ? 'Click to edit job title' : undefined} + > + {data.title} + + ) : ( + { + if (!isLocked) { + e.stopPropagation(); + setIsEditingTitle(true); + } + }} + title={!isLocked ? 'Click to add job title' : 'No job title set'} + > + {!isLocked ? '+ Add job title' : 'No title'} + + )} +
+
+ +
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/people/org-chart/components/PeopleSidebar.tsx b/apps/app/src/app/(app)/[orgId]/people/org-chart/components/PeopleSidebar.tsx new file mode 100644 index 000000000..dde1da22e --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/org-chart/components/PeopleSidebar.tsx @@ -0,0 +1,167 @@ +'use client'; + +import { useState } from 'react'; +import { Button } from '@trycompai/design-system'; +import { Add } from '@trycompai/design-system/icons'; +import type { OrgChartMember } from '../types'; + +interface PeopleSidebarProps { + members: OrgChartMember[]; + onAddMember: (person: { + name: string; + title: string; + memberId?: string; + }) => void; + /** Set of member IDs already placed on the chart */ + placedMemberIds: Set; +} + +export function PeopleSidebar({ + members, + onAddMember, + placedMemberIds, +}: PeopleSidebarProps) { + const [search, setSearch] = useState(''); + const [showCustomForm, setShowCustomForm] = useState(false); + const [customName, setCustomName] = useState(''); + const [customTitle, setCustomTitle] = useState(''); + + const filteredMembers = members.filter( + (m) => + m.user.name.toLowerCase().includes(search.toLowerCase()) || + m.user.email.toLowerCase().includes(search.toLowerCase()), + ); + + const handleAddMember = (member: OrgChartMember) => { + onAddMember({ + name: member.user.name, + title: member.jobTitle || '', + memberId: member.id, + }); + }; + + const handleAddCustom = () => { + if (!customName.trim()) return; + onAddMember({ name: customName.trim(), title: customTitle.trim() }); + setCustomName(''); + setCustomTitle(''); + setShowCustomForm(false); + }; + + return ( +
+ {/* Header */} +
+

+ People +

+ setSearch(e.target.value)} + className="w-full rounded-md border border-border bg-background px-2.5 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none" + /> +
+ + {/* Member list */} +
+ {filteredMembers.length === 0 ? ( +

+ No members found +

+ ) : ( + filteredMembers.map((member) => { + const isPlaced = placedMemberIds.has(member.id); + return ( + + ); + }) + )} +
+ + {/* Add custom person */} +
+ {showCustomForm ? ( +
+ setCustomName(e.target.value)} + className="w-full rounded-md border border-border bg-background px-2.5 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none" + /> + setCustomTitle(e.target.value)} + className="w-full rounded-md border border-border bg-background px-2.5 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none" + /> +
+ + +
+
+ ) : ( + + )} +
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/people/org-chart/components/UploadOrgChartDialog.tsx b/apps/app/src/app/(app)/[orgId]/people/org-chart/components/UploadOrgChartDialog.tsx new file mode 100644 index 000000000..56fa6b55a --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/org-chart/components/UploadOrgChartDialog.tsx @@ -0,0 +1,152 @@ +'use client'; + +import { useCallback, useState } from 'react'; +import Dropzone, { type FileRejection } from 'react-dropzone'; +import { Button } from '@trycompai/design-system'; +import { Upload, Close } from '@trycompai/design-system/icons'; +import { useApi } from '@/hooks/use-api'; +import { toast } from 'sonner'; +import { useRouter } from 'next/navigation'; + +interface UploadOrgChartDialogProps { + onClose: () => void; +} + +export function UploadOrgChartDialog({ onClose }: UploadOrgChartDialogProps) { + const api = useApi(); + const router = useRouter(); + const [isUploading, setIsUploading] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); + + const onDrop = useCallback( + (acceptedFiles: File[], rejectedFiles: FileRejection[]) => { + if (rejectedFiles.length > 0) { + toast.error('Invalid file type. Please upload a PNG, JPG, or PDF.'); + return; + } + if (acceptedFiles.length > 0) { + setSelectedFile(acceptedFiles[0]); + } + }, + [], + ); + + const handleUpload = async () => { + if (!selectedFile) return; + + setIsUploading(true); + try { + const reader = new FileReader(); + const base64 = await new Promise((resolve, reject) => { + reader.onload = () => { + const result = reader.result as string; + // Strip the data URL prefix to get pure base64 + const base64Data = result.split(',')[1]; + resolve(base64Data); + }; + reader.onerror = reject; + reader.readAsDataURL(selectedFile); + }); + + const response = await api.post('/v1/org-chart/upload', { + fileName: selectedFile.name, + fileType: selectedFile.type, + fileData: base64, + }); + + if (response.error) { + toast.error('Failed to upload org chart'); + return; + } + + toast.success('Org chart uploaded'); + router.refresh(); + onClose(); + } catch { + toast.error('Failed to upload org chart'); + } finally { + setIsUploading(false); + } + }; + + return ( +
+
+

+ Upload Org Chart +

+ +
+ + + {({ getRootProps, getInputProps, isDragActive }) => ( +
)} + className={`grid h-48 w-full cursor-pointer place-items-center rounded-lg border-2 border-dashed transition ${ + isDragActive + ? 'border-primary/50 bg-primary/5' + : 'border-border hover:bg-muted/25' + }`} + > + +
+
+ +
+ {selectedFile ? ( +
+

+ {selectedFile.name} +

+

+ {(selectedFile.size / 1024).toFixed(1)} KB - Click or drop to + replace +

+
+ ) : ( +
+

+ Drop an image here or click to browse +

+

+ Supports PNG, JPG, PDF (up to 100MB) +

+
+ )} +
+
+ )} +
+ +
+ + +
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/people/org-chart/types.ts b/apps/app/src/app/(app)/[orgId]/people/org-chart/types.ts new file mode 100644 index 000000000..aa3ed585f --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/org-chart/types.ts @@ -0,0 +1,9 @@ +export interface OrgChartMember { + id: string; + user: { + name: string; + email: string; + }; + role: string; + jobTitle?: string | null; +} diff --git a/apps/app/src/app/(app)/[orgId]/people/page.tsx b/apps/app/src/app/(app)/[orgId]/people/page.tsx index ec0f50eed..0af12e6b1 100644 --- a/apps/app/src/app/(app)/[orgId]/people/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/page.tsx @@ -1,4 +1,7 @@ import { auth } from '@/utils/auth'; +import { s3Client, BUCKET_NAME } from '@/app/s3'; +import { GetObjectCommand } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { db } from '@db'; import type { Metadata } from 'next'; import { headers } from 'next/headers'; @@ -10,6 +13,7 @@ import { DeviceComplianceChart } from './devices/components/DeviceComplianceChar import { EmployeeDevicesList } from './devices/components/EmployeeDevicesList'; import { getEmployeeDevices } from './devices/data'; import type { Host } from './devices/types'; +import { OrgChartContent } from './org-chart/components/OrgChartContent'; export default async function PeoplePage({ params }: { params: Promise<{ orgId: string }> }) { const { orgId } = await params; @@ -34,21 +38,85 @@ export default async function PeoplePage({ params }: { params: Promise<{ orgId: const canInviteUsers = canManageMembers || isAuditor; const isCurrentUserOwner = currentUserRoles.includes('owner'); - // Check if there are employees to show the Employee Tasks tab - const allMembers = await db.member.findMany({ + // Fetch members with user info (used for both employee check and org chart) + const membersWithUsers = await db.member.findMany({ where: { organizationId: orgId, deactivated: false, }, + include: { + user: { + select: { + name: true, + email: true, + }, + }, + }, }); - const employees = allMembers.filter((member) => { + // Check if there are employees to show the Employee Tasks tab + const employees = membersWithUsers.filter((member) => { const roles = member.role.includes(',') ? member.role.split(',') : [member.role]; return roles.includes('employee') || roles.includes('contractor'); }); const showEmployeeTasks = employees.length > 0; + // Fetch org chart data directly via Prisma + const orgChart = await db.organizationChart.findUnique({ + where: { organizationId: orgId }, + }); + + // Generate a signed URL for uploaded images + let orgChartData = null; + if (orgChart) { + let signedImageUrl: string | null = null; + if ( + orgChart.type === 'uploaded' && + orgChart.uploadedImageUrl && + s3Client && + BUCKET_NAME + ) { + try { + const cmd = new GetObjectCommand({ + Bucket: BUCKET_NAME, + Key: orgChart.uploadedImageUrl, + }); + signedImageUrl = await getSignedUrl(s3Client, cmd, { expiresIn: 900 }); + } catch { + // Signed URL generation failed; image won't render + } + } + + // Sanitize nodes/edges from JSON to ensure valid React Flow structures + const rawNodes = Array.isArray(orgChart.nodes) ? orgChart.nodes : []; + const rawEdges = Array.isArray(orgChart.edges) ? orgChart.edges : []; + + const sanitizedNodes = (rawNodes as Record[]) + .filter((n) => n && typeof n === 'object' && n.id) + .map((n) => ({ + ...n, + position: n.position && typeof (n.position as Record).x === 'number' + ? n.position + : { x: 0, y: 0 }, + })); + + const sanitizedEdges = (rawEdges as Record[]) + .filter((e) => e && typeof e === 'object' && e.source && e.target) + .map((e, i) => ({ + ...e, + id: e.id || `edge-${e.source}-${e.target}-${i}`, + })); + + orgChartData = { + ...orgChart, + nodes: sanitizedNodes, + edges: sanitizedEdges, + updatedAt: orgChart.updatedAt.toISOString(), + signedImageUrl, + }; + } + // Fetch devices data let devices: Host[] = []; try { @@ -76,6 +144,12 @@ export default async function PeoplePage({ params }: { params: Promise<{ orgId: } + orgChartContent={ + + } showEmployeeTasks={showEmployeeTasks} canInviteUsers={canInviteUsers} canManageMembers={canManageMembers} diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/actions/delete-policy-pdf.ts b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/actions/delete-policy-pdf.ts index 001bdb932..6fbd681ac 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/actions/delete-policy-pdf.ts +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/actions/delete-policy-pdf.ts @@ -34,8 +34,9 @@ export const deletePolicyPdfAction = authActionClient // Verify policy belongs to organization const policy = await db.policy.findUnique({ where: { id: policyId, organizationId }, - select: { - id: true, + select: { + id: true, + status: true, pdfUrl: true, currentVersionId: true, pendingVersionId: true, @@ -59,8 +60,9 @@ export const deletePolicyPdfAction = authActionClient return { success: false, error: 'Version not found' }; } - // Don't allow deleting PDF from published or pending versions - if (version.id === policy.currentVersionId) { + // Don't allow deleting PDF from the current version unless policy is in draft + // This covers both 'published' and 'needs_review' states + if (version.id === policy.currentVersionId && policy.status !== 'draft') { return { success: false, error: 'Cannot delete PDF from the published version' }; } if (version.id === policy.pendingVersionId) { diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/actions/upload-policy-pdf.ts b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/actions/upload-policy-pdf.ts index 23f06d603..fd2205167 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/actions/upload-policy-pdf.ts +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/actions/upload-policy-pdf.ts @@ -41,8 +41,9 @@ export const uploadPolicyPdfAction = authActionClient // Verify policy belongs to organization const policy = await db.policy.findUnique({ where: { id: policyId, organizationId }, - select: { - id: true, + select: { + id: true, + status: true, pdfUrl: true, currentVersionId: true, pendingVersionId: true, @@ -66,8 +67,9 @@ export const uploadPolicyPdfAction = authActionClient return { success: false, error: 'Version not found' }; } - // Don't allow uploading PDF to published or pending versions - if (version.id === policy.currentVersionId) { + // Don't allow uploading PDF to the current version unless policy is in draft + // This covers both 'published' and 'needs_review' states + if (version.id === policy.currentVersionId && policy.status !== 'draft') { return { success: false, error: 'Cannot upload PDF to the published version' }; } if (version.id === policy.pendingVersionId) { diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PdfViewer.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PdfViewer.tsx index 8b4c7cfcf..b08122803 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PdfViewer.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PdfViewer.tsx @@ -97,19 +97,27 @@ export function PdfViewer({ }, [pdfUrl, policyId, versionId, getUrl]); const { execute: upload, status: uploadStatus } = useAction(uploadPolicyPdfAction, { - onSuccess: () => { - toast.success('PDF uploaded successfully.'); - setFiles([]); - onMutate?.(); + onSuccess: (result) => { + if (result?.data?.success) { + toast.success('PDF uploaded successfully.'); + setFiles([]); + onMutate?.(); + } else { + toast.error(result?.data?.error || 'Failed to upload PDF.'); + } }, onError: (error) => toast.error(error.error.serverError || 'Failed to upload PDF.'), }); const { execute: deletePdf, status: deleteStatus } = useAction(deletePolicyPdfAction, { - onSuccess: () => { - toast.success('PDF deleted successfully.'); - setSignedUrl(null); - onMutate?.(); + onSuccess: (result) => { + if (result?.data?.success) { + toast.success('PDF deleted successfully.'); + setSignedUrl(null); + onMutate?.(); + } else { + toast.error(result?.data?.error || 'Failed to delete PDF.'); + } }, onError: (error) => toast.error(error.error.serverError || 'Failed to delete PDF.'), }); diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/actions/prompts/automation-suggestions.ts b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/actions/prompts/automation-suggestions.ts index f49dd1ce0..15029004e 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/actions/prompts/automation-suggestions.ts +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/actions/prompts/automation-suggestions.ts @@ -30,7 +30,7 @@ The suggestions should help collect evidence that directly relates to verifying export const AUTOMATION_SUGGESTIONS_SYSTEM_PROMPT = `You are an expert at creating automation suggestions for compliance evidence collection. IMPORTANT CONTEXT: -- The automation agent can ONLY read and fetch data from APIs (no write/modify operations) +- The automation agent can ONLY read and fetch data from APIs (no write/modify operations). Some APIs require POST for data retrieval — this is allowed when POST is used solely for querying/fetching data, not for creating or modifying resources. - The agent will write an integration with any API to pull necessary evidence to verify compliance - These are read-only evidence collection automations that check compliance status - Automations connect to vendor APIs/integrations to programmatically fetch data - NO screenshots or manual collection diff --git a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/actions/check-dns-record.ts b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/actions/check-dns-record.ts index 377463f19..7e49099fa 100644 --- a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/actions/check-dns-record.ts +++ b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/actions/check-dns-record.ts @@ -4,9 +4,12 @@ import { authActionClient } from '@/actions/safe-action'; import { env } from '@/env.mjs'; import { db } from '@db'; import { Vercel } from '@vercel/sdk'; +import * as dns from 'node:dns'; import { revalidatePath, revalidateTag } from 'next/cache'; import { z } from 'zod'; +const dnsPromises = dns.promises; + /** * Strict pattern to match known Vercel DNS CNAME targets. * Matches formats like: @@ -57,34 +60,17 @@ export const checkDnsRecordAction = authActionClient const rootDomain = domain.split('.').slice(-2).join('.'); const activeOrgId = ctx.session.activeOrganizationId; - const response = await fetch(`https://networkcalc.com/api/dns/lookup/${domain}`); - const txtResponse = await fetch( - `https://networkcalc.com/api/dns/lookup/${rootDomain}?type=TXT`, - ); - const vercelTxtResponse = await fetch( - `https://networkcalc.com/api/dns/lookup/_vercel.${rootDomain}?type=TXT`, - ); - - const data = await response.json(); - const txtData = await txtResponse.json(); - const vercelTxtData = await vercelTxtResponse.json(); - - if ( - response.status !== 200 || - data.status !== 'OK' || - txtResponse.status !== 200 || - txtData.status !== 'OK' - ) { - console.error('DNS lookup failed:', data); - throw new Error( - data.message || - 'DNS record verification failed, check the records are valid or try again later.', - ); - } - - const cnameRecords = data.records?.CNAME; - const txtRecords = txtData.records?.TXT; - const vercelTxtRecords = vercelTxtData.records?.TXT; + // Use Node's built-in DNS (no HTTPS) to avoid SSL/certificate issues with external APIs + const getCnameRecords = (host: string): Promise => + dnsPromises.resolve(host, 'CNAME').catch(() => []); + const getTxtRecords = (host: string): Promise => + dnsPromises.resolve(host, 'TXT').catch(() => []); + + const [cnameRecords, txtRecords, vercelTxtRecords] = await Promise.all([ + getCnameRecords(domain), + getTxtRecords(rootDomain), + getTxtRecords(`_vercel.${rootDomain}`), + ]); const isVercelDomain = await db.trust.findUnique({ where: { organizationId: activeOrgId, @@ -100,61 +86,38 @@ export const checkDnsRecordAction = authActionClient let isCnameVerified = false; - if (cnameRecords) { + if (cnameRecords.length > 0) { // First try strict pattern - isCnameVerified = cnameRecords.some((record: { address: string }) => - VERCEL_DNS_CNAME_PATTERN.test(record.address), + isCnameVerified = cnameRecords.some((address) => + VERCEL_DNS_CNAME_PATTERN.test(address), ); // If strict fails, try fallback pattern (catches new Vercel patterns we haven't seen) if (!isCnameVerified) { - const fallbackMatch = cnameRecords.find((record: { address: string }) => - VERCEL_DNS_FALLBACK_PATTERN.test(record.address), + const fallbackMatch = cnameRecords.find((address) => + VERCEL_DNS_FALLBACK_PATTERN.test(address), ); if (fallbackMatch) { console.warn( `[DNS Check] CNAME matched fallback pattern but not strict pattern. ` + - `Address: ${fallbackMatch.address}. Consider updating VERCEL_DNS_CNAME_PATTERN.`, + `Address: ${fallbackMatch}. Consider updating VERCEL_DNS_CNAME_PATTERN.`, ); isCnameVerified = true; } } } - let isTxtVerified = false; - let isVercelTxtVerified = false; + // Node's resolve(host, 'TXT') returns string[][] - each inner array is one TXT record + const txtRecordMatches = (records: string[][], expected: string | null) => + expected != null && + records.some((segments) => segments.some((s) => s === expected)); - if (txtRecords) { - // Check for our custom TXT record - isTxtVerified = txtRecords.some((record: any) => { - if (typeof record === 'string') { - return record === expectedTxtValue; - } - if (record && typeof record.value === 'string') { - return record.value === expectedTxtValue; - } - if (record && Array.isArray(record.txt) && record.txt.length > 0) { - return record.txt.some((txt: string) => txt === expectedTxtValue); - } - return false; - }); - } - - if (vercelTxtRecords) { - isVercelTxtVerified = vercelTxtRecords.some((record: any) => { - if (typeof record === 'string') { - return record === expectedVercelTxtValue; - } - if (record && typeof record.value === 'string') { - return record.value === expectedVercelTxtValue; - } - if (record && Array.isArray(record.txt) && record.txt.length > 0) { - return record.txt.some((txt: string) => txt === expectedVercelTxtValue); - } - return false; - }); - } + const isTxtVerified = txtRecordMatches(txtRecords, expectedTxtValue); + const isVercelTxtVerified = txtRecordMatches( + vercelTxtRecords, + expectedVercelTxtValue ?? null, + ); const isVerified = isCnameVerified && isTxtVerified && isVercelTxtVerified; diff --git a/apps/app/src/components/tables/people/employee-status.tsx b/apps/app/src/components/tables/people/employee-status.tsx index fe2157e72..1fcf97bfb 100644 --- a/apps/app/src/components/tables/people/employee-status.tsx +++ b/apps/app/src/components/tables/people/employee-status.tsx @@ -1,10 +1,15 @@ -import { EMPLOYEE_STATUS_HEX_COLORS } from '@/app/(app)/[orgId]/people/[employeeId]/components/Fields/Status'; import { cn } from '@comp/ui/cn'; // Define employee status types export const EMPLOYEE_STATUS_TYPES = ['active', 'inactive'] as const; export type EmployeeStatusType = (typeof EMPLOYEE_STATUS_TYPES)[number]; +// Status color hex values for charts +export const EMPLOYEE_STATUS_HEX_COLORS: Record = { + inactive: 'var(--color-destructive)', + active: 'var(--color-primary)', +}; + /** * EmployeeStatus component that matches the styling of the Status component * but uses active/inactive states specific to employees diff --git a/apps/app/src/lib/org-chart.ts b/apps/app/src/lib/org-chart.ts new file mode 100644 index 000000000..b73d809a3 --- /dev/null +++ b/apps/app/src/lib/org-chart.ts @@ -0,0 +1,48 @@ +import { db, Prisma } from '@db'; + +/** + * Removes a member's node (and all connected edges) from the org chart. + * No-op if the org chart doesn't exist or the member has no node on it. + */ +export async function removeMemberFromOrgChart( + organizationId: string, + memberId: string, +): Promise { + const orgChart = await db.organizationChart.findUnique({ + where: { organizationId }, + }); + + if (!orgChart) return; + + const chartNodes = (Array.isArray(orgChart.nodes) ? orgChart.nodes : []) as Array< + Record + >; + const chartEdges = (Array.isArray(orgChart.edges) ? orgChart.edges : []) as Array< + Record + >; + + const removedNodeIds = new Set( + chartNodes + .filter((n) => { + const data = n.data as Record | undefined; + return data?.memberId === memberId; + }) + .map((n) => n.id as string), + ); + + if (removedNodeIds.size === 0) return; + + const updatedNodes = chartNodes.filter((n) => !removedNodeIds.has(n.id as string)); + const updatedEdges = chartEdges.filter( + (e) => + !removedNodeIds.has(e.source as string) && !removedNodeIds.has(e.target as string), + ); + + await db.organizationChart.update({ + where: { organizationId }, + data: { + nodes: updatedNodes as unknown as Prisma.InputJsonValue, + edges: updatedEdges as unknown as Prisma.InputJsonValue, + }, + }); +} diff --git a/apps/app/src/test-utils/mocks/auth.ts b/apps/app/src/test-utils/mocks/auth.ts index dbc2307bd..e275f516c 100644 --- a/apps/app/src/test-utils/mocks/auth.ts +++ b/apps/app/src/test-utils/mocks/auth.ts @@ -72,6 +72,7 @@ export const createMockMember = (overrides?: Partial): Member => ({ department: Departments.none, isActive: true, fleetDmLabelId: null, + jobTitle: null, deactivated: false, ...overrides, }); diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/DeviceAgentAccordionItem.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/DeviceAgentAccordionItem.tsx index d9975c867..70b141776 100644 --- a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/DeviceAgentAccordionItem.tsx +++ b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/DeviceAgentAccordionItem.tsx @@ -253,7 +253,12 @@ export function DeviceAgentAccordionItem({ {fleetPolicies.length > 0 ? ( <> {fleetPolicies.map((policy) => ( - + ))} ) : ( diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/FleetPolicyItem.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/FleetPolicyItem.tsx index fb97c810f..1f2b5f93f 100644 --- a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/FleetPolicyItem.tsx +++ b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/FleetPolicyItem.tsx @@ -5,7 +5,7 @@ import { useMemo, useState } from 'react'; import { Button } from '@comp/ui/button'; import { cn } from '@comp/ui/cn'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@comp/ui/tooltip'; -import { CheckCircle2, HelpCircle, Image, MoreVertical, Upload, XCircle } from 'lucide-react'; +import { CheckCircle2, HelpCircle, Image, MoreVertical, Trash, Upload, XCircle } from 'lucide-react'; import { DropdownMenu, DropdownMenuContent, @@ -15,15 +15,18 @@ import { import type { FleetPolicy } from '../../types'; import { PolicyImageUploadModal } from './PolicyImageUploadModal'; import { PolicyImagePreviewModal } from './PolicyImagePreviewModal'; +import { PolicyImageResetModal } from './PolicyImageResetModal'; interface FleetPolicyItemProps { policy: FleetPolicy; + organizationId: string; onRefresh: () => void; } -export function FleetPolicyItem({ policy, onRefresh }: FleetPolicyItemProps) { +export function FleetPolicyItem({ policy, organizationId, onRefresh }: FleetPolicyItemProps) { const [isUploadOpen, setIsUploadOpen] = useState(false); const [isPreviewOpen, setIsPreviewOpen] = useState(false); + const [isRemoveOpen, setIsRemoveOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false); const actions = useMemo(() => { @@ -35,6 +38,11 @@ export function FleetPolicyItem({ policy, onRefresh }: FleetPolicyItemProps) { renderIcon: () => , onClick: () => setIsPreviewOpen(true), }, + { + label: 'Remove images', + renderIcon: () => , + onClick: () => setIsRemoveOpen(true), + }, ]; } @@ -131,6 +139,7 @@ export function FleetPolicyItem({ policy, onRefresh }: FleetPolicyItemProps) { + ); } \ No newline at end of file diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageResetModal.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageResetModal.tsx new file mode 100644 index 000000000..ea510dc71 --- /dev/null +++ b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageResetModal.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { useState } from 'react'; + +import { Button } from '@comp/ui/button'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@comp/ui/dialog'; +import { Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; + +interface PolicyImageResetModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + organizationId: string; + policyId: number; + onRefresh: () => void; +} + +export function PolicyImageResetModal({ + open, + onOpenChange, + organizationId, + policyId, + onRefresh, +}: PolicyImageResetModalProps) { + const [isDeleting, setIsDeleting] = useState(false); + + const handleConfirm = async () => { + setIsDeleting(true); + try { + const params = new URLSearchParams({ organizationId, policyId: String(policyId) }); + const res = await fetch(`/api/fleet-policy?${params}`, { method: 'DELETE' }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data?.error ?? 'Failed to remove images'); + } + onRefresh(); + onOpenChange(false); + toast.success('Images removed'); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to remove images'); + } finally { + setIsDeleting(false); + } + }; + + return ( + { + if (!isDeleting || nextOpen) onOpenChange(nextOpen); + }} + > + + + Remove all images + +

+ Are you sure you want to remove all images? +

+ + + + +
+
+ ); +} diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageUploadModal.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageUploadModal.tsx index 7530dac6f..5f04e63a9 100644 --- a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageUploadModal.tsx +++ b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageUploadModal.tsx @@ -12,24 +12,27 @@ import { } from '@comp/ui/dialog'; import { ImagePlus, Trash2, Loader2 } from 'lucide-react'; import Image from 'next/image'; -import { useParams } from 'next/navigation'; import { toast } from 'sonner'; import { FleetPolicy } from '../../types'; interface PolicyImageUploadModalProps { open: boolean; policy: FleetPolicy; + organizationId: string; onOpenChange: (open: boolean) => void; onRefresh: () => void; } -export function PolicyImageUploadModal({ open, policy, onOpenChange, onRefresh }: PolicyImageUploadModalProps) { +export function PolicyImageUploadModal({ + open, + policy, + organizationId, + onOpenChange, + onRefresh, +}: PolicyImageUploadModalProps) { const fileInputRef = useRef(null); const [files, setFiles] = useState>([]); const [isLoading, setIsLoading] = useState(false); - const params = useParams<{ orgId: string }>(); - const orgIdParam = params?.orgId; - const organizationId = Array.isArray(orgIdParam) ? orgIdParam[0] : orgIdParam; const handleFileChange = (e: React.ChangeEvent) => { const selected = Array.from(e.target.files ?? []); diff --git a/apps/portal/src/app/api/fleet-policy/route.ts b/apps/portal/src/app/api/fleet-policy/route.ts index 906bfde58..0b94e2692 100644 --- a/apps/portal/src/app/api/fleet-policy/route.ts +++ b/apps/portal/src/app/api/fleet-policy/route.ts @@ -1,7 +1,7 @@ import { auth } from '@/app/lib/auth'; import { validateMemberAndOrg } from '@/app/api/download-agent/utils'; import { APP_AWS_ORG_ASSETS_BUCKET, s3Client } from '@/utils/s3'; -import { GetObjectCommand } from '@aws-sdk/client-s3'; +import { DeleteObjectsCommand, GetObjectCommand } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { db } from '@db'; import { NextRequest, NextResponse } from 'next/server'; @@ -61,3 +61,75 @@ export async function GET(req: NextRequest) { return NextResponse.json({ success: true, data: withSignedUrls }); } + +export async function DELETE(req: NextRequest) { + const organizationId = req.nextUrl.searchParams.get('organizationId'); + const policyIdParam = req.nextUrl.searchParams.get('policyId'); + + if (!organizationId) { + return NextResponse.json({ error: 'No organization ID' }, { status: 400 }); + } + + const policyId = policyIdParam ? parseInt(policyIdParam, 10) : NaN; + if (Number.isNaN(policyId)) { + return NextResponse.json({ error: 'Invalid or missing policy ID' }, { status: 400 }); + } + + const session = await auth.api.getSession({ headers: req.headers }); + + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const member = await validateMemberAndOrg(session.user.id, organizationId); + if (!member) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const where = { + organizationId, + fleetPolicyId: policyId, + userId: session.user.id, + }; + + const recordsToDelete = await db.fleetPolicyResult.findMany({ + where, + select: { attachments: true }, + }); + + const allKeys = recordsToDelete.flatMap((r) => r.attachments ?? []).filter(Boolean); + + // Delete DB records first to avoid inconsistent state: if we deleted S3 first and + // DB delete fails, we'd have broken image links. Orphaned S3 objects are preferable. + const result = await db.fleetPolicyResult.deleteMany({ where }); + + const S3_DELETE_MAX_KEYS = 1000; + if (s3Client && APP_AWS_ORG_ASSETS_BUCKET && allKeys.length > 0) { + try { + for (let i = 0; i < allKeys.length; i += S3_DELETE_MAX_KEYS) { + const batch = allKeys.slice(i, i + S3_DELETE_MAX_KEYS); + await s3Client.send( + new DeleteObjectsCommand({ + Bucket: APP_AWS_ORG_ASSETS_BUCKET, + Delete: { + Objects: batch.map((key) => ({ Key: key })), + }, + }), + ); + } + } catch (error) { + // DB is already clean; orphaned S3 objects are acceptable and can be cleaned up later + console.error('Failed to delete policy attachment objects from S3 (orphaned)', { + error, + policyId, + organizationId, + keyCount: allKeys.length, + }); + } + } + + return NextResponse.json({ + success: true, + deletedCount: result.count, + }); +} diff --git a/bun.lock b/bun.lock index f8ccf00e0..fd430dc28 100644 --- a/bun.lock +++ b/bun.lock @@ -222,6 +222,7 @@ "@vercel/analytics": "^1.5.0", "@vercel/sandbox": "^0.0.21", "@vercel/sdk": "^1.7.1", + "@xyflow/react": "^12.10.0", "ai": "^5.0.108", "ai-elements": "^1.6.1", "axios": "^1.9.0", diff --git a/packages/db/prisma/migrations/20260211165134_add_organization_chart/migration.sql b/packages/db/prisma/migrations/20260211165134_add_organization_chart/migration.sql new file mode 100644 index 000000000..76c9e09c4 --- /dev/null +++ b/packages/db/prisma/migrations/20260211165134_add_organization_chart/migration.sql @@ -0,0 +1,23 @@ +-- CreateTable +CREATE TABLE "OrganizationChart" ( + "id" TEXT NOT NULL DEFAULT generate_prefixed_cuid('och'::text), + "organizationId" TEXT NOT NULL, + "name" TEXT NOT NULL DEFAULT 'Organization Chart', + "type" TEXT NOT NULL DEFAULT 'interactive', + "nodes" JSONB NOT NULL DEFAULT '[]', + "edges" JSONB NOT NULL DEFAULT '[]', + "uploadedImageUrl" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "OrganizationChart_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "OrganizationChart_organizationId_key" ON "OrganizationChart"("organizationId"); + +-- CreateIndex +CREATE INDEX "OrganizationChart_organizationId_idx" ON "OrganizationChart"("organizationId"); + +-- AddForeignKey +ALTER TABLE "OrganizationChart" ADD CONSTRAINT "OrganizationChart_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20260211170222_add_member_job_title/migration.sql b/packages/db/prisma/migrations/20260211170222_add_member_job_title/migration.sql new file mode 100644 index 000000000..1ec0ce8b3 --- /dev/null +++ b/packages/db/prisma/migrations/20260211170222_add_member_job_title/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Member" ADD COLUMN "jobTitle" TEXT; diff --git a/packages/db/prisma/schema/auth.prisma b/packages/db/prisma/schema/auth.prisma index 64eca15af..f248ff189 100644 --- a/packages/db/prisma/schema/auth.prisma +++ b/packages/db/prisma/schema/auth.prisma @@ -96,6 +96,7 @@ model Member { createdAt DateTime @default(now()) department Departments @default(none) + jobTitle String? isActive Boolean @default(true) deactivated Boolean @default(false) employeeTrainingVideoCompletion EmployeeTrainingVideoCompletion[] diff --git a/packages/db/prisma/schema/org-chart.prisma b/packages/db/prisma/schema/org-chart.prisma new file mode 100644 index 000000000..2ef7e2998 --- /dev/null +++ b/packages/db/prisma/schema/org-chart.prisma @@ -0,0 +1,14 @@ +model OrganizationChart { + id String @id @default(dbgenerated("generate_prefixed_cuid('och'::text)")) + organizationId String @unique + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + name String @default("Organization Chart") + type String @default("interactive") // "interactive" or "uploaded" + nodes Json @default("[]") + edges Json @default("[]") + uploadedImageUrl String? // S3 key when type="uploaded" + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([organizationId]) +} diff --git a/packages/db/prisma/schema/organization.prisma b/packages/db/prisma/schema/organization.prisma index c673990db..45cbce5b5 100644 --- a/packages/db/prisma/schema/organization.prisma +++ b/packages/db/prisma/schema/organization.prisma @@ -62,5 +62,8 @@ model Organization { // Findings findings Finding[] + // Org Chart + organizationChart OrganizationChart? + @@index([slug]) } diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index 490dfe89f..7eb1ee6d1 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -6100,6 +6100,107 @@ } }, "/v1/policies/{id}/versions/{versionId}": { + "get": { + "description": "Returns a single policy version by its ID, including content and metadata.", + "operationId": "PoliciesController_getPolicyVersionById_v1", + "parameters": [ + { + "name": "X-Organization-Id", + "in": "header", + "description": "Organization ID (required for session auth, optional for API key auth)", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "id", + "required": true, + "in": "path", + "description": "Policy ID", + "schema": { + "type": "string", + "example": "pol_abc123def456" + } + }, + { + "name": "versionId", + "required": true, + "in": "path", + "description": "Policy version ID", + "schema": { + "type": "string", + "example": "pv_abc123def456" + } + } + ], + "responses": { + "200": { + "description": "Policy version retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "version": { + "type": "object" + }, + "currentVersionId": { + "type": "string", + "nullable": true + }, + "pendingVersionId": { + "type": "string", + "nullable": true + } + } + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Unauthorized" + } + } + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Resource not found" + } + } + } + } + } + } + }, + "security": [ + { + "apikey": [] + } + ], + "summary": "Get policy version by ID", + "tags": [ + "Policies" + ] + }, "patch": { "description": "Updates content for a non-published, non-pending version. Published and pending versions are immutable.", "operationId": "PoliciesController_updateVersionContent_v1", @@ -15596,6 +15697,132 @@ "Training" ] } + }, + "/v1/org-chart": { + "get": { + "operationId": "OrgChartController_getOrgChart_v1", + "parameters": [ + { + "name": "X-Organization-Id", + "in": "header", + "description": "Organization ID (required for session auth, optional for API key auth)", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The organization chart" + } + }, + "security": [ + { + "apikey": [] + } + ], + "summary": "Get the organization chart", + "tags": [ + "Org Chart" + ] + }, + "put": { + "operationId": "OrgChartController_upsertOrgChart_v1", + "parameters": [ + { + "name": "X-Organization-Id", + "in": "header", + "description": "Organization ID (required for session auth, optional for API key auth)", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The saved organization chart" + } + }, + "security": [ + { + "apikey": [] + } + ], + "summary": "Create or update an interactive organization chart", + "tags": [ + "Org Chart" + ] + }, + "delete": { + "operationId": "OrgChartController_deleteOrgChart_v1", + "parameters": [ + { + "name": "X-Organization-Id", + "in": "header", + "description": "Organization ID (required for session auth, optional for API key auth)", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Deletion confirmation" + } + }, + "security": [ + { + "apikey": [] + } + ], + "summary": "Delete the organization chart", + "tags": [ + "Org Chart" + ] + } + }, + "/v1/org-chart/upload": { + "post": { + "operationId": "OrgChartController_uploadOrgChart_v1", + "parameters": [ + { + "name": "X-Organization-Id", + "in": "header", + "description": "Organization ID (required for session auth, optional for API key auth)", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UploadOrgChartDto" + } + } + } + }, + "responses": { + "201": { + "description": "The uploaded organization chart" + } + }, + "security": [ + { + "apikey": [] + } + ], + "summary": "Upload an image as the organization chart", + "tags": [ + "Org Chart" + ] + } } }, "info": { @@ -15727,6 +15954,12 @@ ], "example": "it" }, + "jobTitle": { + "type": "object", + "description": "Job title for the member", + "example": "Software Engineer", + "nullable": true + }, "isActive": { "type": "boolean", "description": "Whether member is active", @@ -15754,6 +15987,7 @@ "role", "createdAt", "department", + "jobTitle", "isActive", "fleetDmLabelId", "user" @@ -15795,6 +16029,11 @@ "type": "number", "description": "FleetDM label ID for member devices", "example": 123 + }, + "jobTitle": { + "type": "string", + "description": "Job title for the member", + "example": "Software Engineer" } }, "required": [ @@ -15868,6 +16107,11 @@ "type": "number", "description": "FleetDM label ID for member devices", "example": 123 + }, + "jobTitle": { + "type": "string", + "description": "Job title for the member", + "example": "Software Engineer" } } }, @@ -19371,6 +19615,28 @@ "required": [ "sent" ] + }, + "UploadOrgChartDto": { + "type": "object", + "properties": { + "fileName": { + "type": "string", + "description": "Original file name" + }, + "fileType": { + "type": "string", + "description": "MIME type of the file (e.g. image/png)" + }, + "fileData": { + "type": "string", + "description": "Base64-encoded file data" + } + }, + "required": [ + "fileName", + "fileType", + "fileData" + ] } } }