diff --git a/apps/api/src/attachments/attachments.service.ts b/apps/api/src/attachments/attachments.service.ts index deb1376ee..48a2e97f2 100644 --- a/apps/api/src/attachments/attachments.service.ts +++ b/apps/api/src/attachments/attachments.service.ts @@ -1,4 +1,5 @@ import { + CopyObjectCommand, DeleteObjectCommand, GetObjectCommand, PutObjectCommand, @@ -334,6 +335,44 @@ export class AttachmentsService { } } + /** + * Copy a policy PDF to a new S3 key for versioning + */ + async copyPolicyVersionPdf( + sourceKey: string, + destinationKey: string, + ): Promise { + try { + await this.s3Client.send( + new CopyObjectCommand({ + Bucket: this.bucketName, + CopySource: `${this.bucketName}/${sourceKey}`, + Key: destinationKey, + }), + ); + return destinationKey; + } catch (error) { + console.error('Error copying policy PDF:', error); + return null; + } + } + + /** + * Delete a policy version PDF from S3 + */ + async deletePolicyVersionPdf(s3Key: string): Promise { + try { + await this.s3Client.send( + new DeleteObjectCommand({ + Bucket: this.bucketName, + Key: s3Key, + }), + ); + } catch (error) { + console.error('Error deleting policy PDF:', error); + } + } + /** * Generate signed URL for file download */ diff --git a/apps/api/src/findings/dto/update-finding.dto.ts b/apps/api/src/findings/dto/update-finding.dto.ts index 7c0ade1b7..16fc7ed2c 100644 --- a/apps/api/src/findings/dto/update-finding.dto.ts +++ b/apps/api/src/findings/dto/update-finding.dto.ts @@ -39,4 +39,16 @@ export class UpdateFindingDto { @IsNotEmpty({ message: 'Content cannot be empty if provided' }) @MaxLength(5000) content?: string; + + @ApiProperty({ + description: 'Auditor note when requesting revision (only for needs_revision status)', + example: 'Please provide clearer screenshots showing the timestamp.', + maxLength: 2000, + required: false, + nullable: true, + }) + @IsString() + @IsOptional() + @MaxLength(2000) + revisionNote?: string | null; } diff --git a/apps/api/src/findings/findings.service.ts b/apps/api/src/findings/findings.service.ts index 9b7d18e66..0ec3955fe 100644 --- a/apps/api/src/findings/findings.service.ts +++ b/apps/api/src/findings/findings.service.ts @@ -71,9 +71,7 @@ export class FindingsService { ], }); - this.logger.log( - `Retrieved ${findings.length} findings for task ${taskId}`, - ); + this.logger.log(`Retrieved ${findings.length} findings for task ${taskId}`); return findings; } @@ -311,19 +309,38 @@ export class FindingsService { } // ready_for_review can only be set by non-auditor admins/owners (client signals to auditor) - if (updateDto.status === FindingStatus.ready_for_review && isAuditor && !isPlatformAdmin) { + if ( + updateDto.status === FindingStatus.ready_for_review && + isAuditor && + !isPlatformAdmin + ) { throw new ForbiddenException( `Auditors cannot set status to 'ready_for_review'. This status is for clients to signal readiness.`, ); } } + // Handle revisionNote logic: + // - Set revisionNote when status is needs_revision and a note is provided + // - Clear revisionNote when status changes to anything other than needs_revision + let revisionNoteUpdate: { revisionNote?: string | null } = {}; + if (updateDto.status === FindingStatus.needs_revision) { + // Set revision note if provided (can be null to clear) + if (updateDto.revisionNote !== undefined) { + revisionNoteUpdate = { revisionNote: updateDto.revisionNote || null }; + } + } else if (updateDto.status !== undefined) { + // Clear revision note when moving to a different status + revisionNoteUpdate = { revisionNote: null }; + } + const updatedFinding = await db.finding.update({ where: { id: findingId }, data: { ...(updateDto.status !== undefined && { status: updateDto.status }), ...(updateDto.type !== undefined && { type: updateDto.type }), ...(updateDto.content !== undefined && { content: updateDto.content }), + ...revisionNoteUpdate, }, include: { createdBy: { @@ -411,22 +428,34 @@ export class FindingsService { switch (updateDto.status) { case FindingStatus.ready_for_review: - this.logger.log(`Triggering 'ready_for_review' notification for finding ${findingId}`); + this.logger.log( + `Triggering 'ready_for_review' notification for finding ${findingId}`, + ); void this.findingNotifierService.notifyReadyForReview({ ...notificationParams, findingCreatorMemberId: finding.createdById, }); break; case FindingStatus.needs_revision: - this.logger.log(`Triggering 'needs_revision' notification for finding ${findingId}`); - void this.findingNotifierService.notifyNeedsRevision(notificationParams); + this.logger.log( + `Triggering 'needs_revision' notification for finding ${findingId}`, + ); + void this.findingNotifierService.notifyNeedsRevision( + notificationParams, + ); break; case FindingStatus.closed: - this.logger.log(`Triggering 'closed' notification for finding ${findingId}`); - void this.findingNotifierService.notifyFindingClosed(notificationParams); + this.logger.log( + `Triggering 'closed' notification for finding ${findingId}`, + ); + void this.findingNotifierService.notifyFindingClosed( + notificationParams, + ); break; case FindingStatus.open: - this.logger.log(`Status changed to 'open' for finding ${findingId}. No notification sent.`); + this.logger.log( + `Status changed to 'open' for finding ${findingId}. No notification sent.`, + ); break; default: this.logger.warn( @@ -489,6 +518,9 @@ export class FindingsService { // Verify finding exists await this.findById(organizationId, findingId); - return this.findingAuditService.getFindingActivity(findingId, organizationId); + return this.findingAuditService.getFindingActivity( + findingId, + organizationId, + ); } } diff --git a/apps/api/src/lib/fleet.service.ts b/apps/api/src/lib/fleet.service.ts index f0888f78e..f23c8245e 100644 --- a/apps/api/src/lib/fleet.service.ts +++ b/apps/api/src/lib/fleet.service.ts @@ -67,6 +67,20 @@ export class FleetService { } } + /** + * Remove a single host from FleetDM by ID + * @param hostId - The FleetDM host ID + */ + async removeHostById(hostId: number): Promise { + try { + await this.fleetInstance.delete(`/hosts/${hostId}`); + this.logger.debug(`Deleted host ${hostId} from FleetDM`); + } catch (error) { + this.logger.error(`Failed to delete host ${hostId}:`, error); + throw new Error(`Failed to remove host ${hostId}`); + } + } + async getMultipleHosts(hostIds: number[]) { try { const requests = hostIds.map((id) => this.getHostById(id)); @@ -104,14 +118,12 @@ export class FleetService { // Extract host IDs const hostIds = labelHosts.hosts.map((host: { id: number }) => host.id); - // Delete each host + // Delete each host using removeHostById for consistent behavior const deletePromises = hostIds.map(async (hostId: number) => { try { - await this.fleetInstance.delete(`/hosts/${hostId}`); - this.logger.debug(`Deleted host ${hostId} from FleetDM`); + await this.removeHostById(hostId); return { success: true, hostId }; - } catch (error) { - this.logger.error(`Failed to delete host ${hostId}:`, error); + } catch { return { success: false, hostId }; } }); diff --git a/apps/api/src/people/people.controller.ts b/apps/api/src/people/people.controller.ts index 89c9bc3e7..8be17cb61 100644 --- a/apps/api/src/people/people.controller.ts +++ b/apps/api/src/people/people.controller.ts @@ -6,6 +6,7 @@ import { Delete, Body, Param, + ParseIntPipe, UseGuards, HttpCode, HttpStatus, @@ -22,6 +23,7 @@ import { } from '@nestjs/swagger'; import { AuthContext, OrganizationId } from '../auth/auth-context.decorator'; import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; +import { RequireRoles } from '../auth/role-validator.guard'; import type { AuthContext as AuthContextType } from '../auth/types'; import { CreatePeopleDto } from './dto/create-people.dto'; import { UpdatePeopleDto } from './dto/update-people.dto'; @@ -34,6 +36,7 @@ import { BULK_CREATE_MEMBERS_RESPONSES } from './schemas/bulk-create-members.res import { GET_PERSON_BY_ID_RESPONSES } from './schemas/get-person-by-id.responses'; import { UPDATE_MEMBER_RESPONSES } from './schemas/update-member.responses'; import { DELETE_MEMBER_RESPONSES } from './schemas/delete-member.responses'; +import { REMOVE_HOST_RESPONSES } from './schemas/remove-host.responses'; import { PEOPLE_OPERATIONS } from './schemas/people-operations'; import { PEOPLE_PARAMS } from './schemas/people-params'; import { PEOPLE_BODIES } from './schemas/people-bodies'; @@ -199,6 +202,41 @@ export class PeopleController { }; } + @Delete(':id/host/:hostId') + @HttpCode(HttpStatus.OK) + @UseGuards(RequireRoles('owner')) + @ApiOperation(PEOPLE_OPERATIONS.removeHost) + @ApiParam(PEOPLE_PARAMS.memberId) + @ApiParam(PEOPLE_PARAMS.hostId) + @ApiResponse(REMOVE_HOST_RESPONSES[200]) + @ApiResponse(REMOVE_HOST_RESPONSES[401]) + @ApiResponse(REMOVE_HOST_RESPONSES[404]) + @ApiResponse(REMOVE_HOST_RESPONSES[500]) + async removeHost( + @Param('id') memberId: string, + @Param('hostId', ParseIntPipe) hostId: number, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + const result = await this.peopleService.removeHostById( + memberId, + organizationId, + hostId, + ); + + return { + ...result, + authType: authContext.authType, + ...(authContext.userId && + authContext.userEmail && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), + }; + } + @Delete(':id') @ApiOperation(PEOPLE_OPERATIONS.deleteMember) @ApiParam(PEOPLE_PARAMS.memberId) diff --git a/apps/api/src/people/people.service.ts b/apps/api/src/people/people.service.ts index 11646475a..19728361e 100644 --- a/apps/api/src/people/people.service.ts +++ b/apps/api/src/people/people.service.ts @@ -338,4 +338,61 @@ export class PeopleService { throw new Error(`Failed to unlink device: ${error.message}`); } } + + async removeHostById( + memberId: string, + organizationId: string, + hostId: number, + ): Promise<{ success: true }> { + try { + await MemberValidator.validateOrganization(organizationId); + const member = await MemberQueries.findByIdInOrganization( + memberId, + organizationId, + ); + + if (!member) { + throw new NotFoundException( + `Member with ID ${memberId} not found in organization ${organizationId}`, + ); + } + + if (!member.fleetDmLabelId) { + throw new BadRequestException( + `Member ${memberId} has no Fleet label; cannot remove host`, + ); + } + + const labelHosts = await this.fleetService.getHostsByLabel( + member.fleetDmLabelId, + ); + const hostIds = (labelHosts?.hosts ?? []).map( + (host: { id: number }) => host.id, + ); + if (!hostIds.includes(hostId)) { + throw new NotFoundException( + `Host ${hostId} not found for member ${memberId} in organization ${organizationId}`, + ); + } + + await this.fleetService.removeHostById(hostId); + + this.logger.log( + `Removed host ${hostId} from FleetDM for member ${memberId} in organization ${organizationId}`, + ); + return { success: true }; + } catch (error) { + if ( + error instanceof NotFoundException || + error instanceof BadRequestException + ) { + throw error; + } + this.logger.error( + `Failed to remove host ${hostId} for member ${memberId} in organization ${organizationId}:`, + error, + ); + throw new Error(`Failed to remove host: ${error.message}`); + } + } } diff --git a/apps/api/src/people/schemas/people-operations.ts b/apps/api/src/people/schemas/people-operations.ts index 5116ba927..f1d700472 100644 --- a/apps/api/src/people/schemas/people-operations.ts +++ b/apps/api/src/people/schemas/people-operations.ts @@ -36,4 +36,9 @@ export const PEOPLE_OPERATIONS: Record = { description: 'Resets the fleetDmLabelId for a member, effectively unlinking their device from FleetDM. This will disconnect the device from the organization. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).', }, + removeHost: { + summary: 'Remove host (device) from Fleet', + description: + 'Removes a single host (device) from FleetDM by host ID. Only organization owners can perform this action. Validates that the organization exists and the member exists within the organization. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).', + }, }; diff --git a/apps/api/src/people/schemas/people-params.ts b/apps/api/src/people/schemas/people-params.ts index 5c8124d46..387f27fc7 100644 --- a/apps/api/src/people/schemas/people-params.ts +++ b/apps/api/src/people/schemas/people-params.ts @@ -6,4 +6,9 @@ export const PEOPLE_PARAMS: Record = { description: 'Member ID', example: 'mem_abc123def456', }, + hostId: { + name: 'hostId', + description: 'FleetDM host ID', + example: 1, + }, }; diff --git a/apps/api/src/people/schemas/remove-host.responses.ts b/apps/api/src/people/schemas/remove-host.responses.ts new file mode 100644 index 000000000..18af92943 --- /dev/null +++ b/apps/api/src/people/schemas/remove-host.responses.ts @@ -0,0 +1,74 @@ +import { ApiResponseOptions } from '@nestjs/swagger'; + +export const REMOVE_HOST_RESPONSES: Record = { + 200: { + status: 200, + description: 'Host removed from Fleet successfully', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + success: { + type: 'boolean', + description: 'Indicates successful removal', + example: true, + }, + authType: { + type: 'string', + enum: ['api-key', 'session'], + description: 'How the request was authenticated', + }, + }, + }, + }, + }, + }, + 401: { + status: 401, + description: + 'Unauthorized - Invalid authentication, insufficient permissions, or not organization owner', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + message: { type: 'string', example: 'Unauthorized' }, + }, + }, + }, + }, + }, + 404: { + status: 404, + description: 'Organization or member not found', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + message: { + type: 'string', + example: + 'Member with ID mem_abc123def456 not found in organization org_abc123def456', + }, + }, + }, + }, + }, + }, + 500: { + status: 500, + description: 'Internal server error', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + message: { type: 'string', example: 'Failed to remove host' }, + }, + }, + }, + }, + }, +}; diff --git a/apps/api/src/policies/dto/version.dto.ts b/apps/api/src/policies/dto/version.dto.ts new file mode 100644 index 000000000..c9a5693d4 --- /dev/null +++ b/apps/api/src/policies/dto/version.dto.ts @@ -0,0 +1,68 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsArray, IsBoolean, IsOptional, IsString } from 'class-validator'; + +export class CreateVersionDto { + @ApiProperty({ + description: 'Optional version ID to base the new version on', + required: false, + example: 'pv_abc123def456', + }) + @IsOptional() + @IsString() + sourceVersionId?: string; + + @ApiProperty({ + description: 'Optional changelog to associate with the new version', + required: false, + example: 'Initial draft for quarterly updates', + }) + @IsOptional() + @IsString() + changelog?: string; +} + +export class UpdateVersionContentDto { + @ApiProperty({ + description: 'Content of the policy version as TipTap JSON (array of nodes)', + example: [ + { + type: 'heading', + attrs: { level: 2, textAlign: null }, + content: [{ type: 'text', text: 'Purpose' }], + }, + ], + type: 'array', + items: { type: 'object', additionalProperties: true }, + }) + @IsArray() + content: unknown[]; +} + +export class PublishVersionDto { + @ApiProperty({ + description: 'Whether to set this version as the active version', + required: false, + example: true, + }) + @IsOptional() + @IsBoolean() + setAsActive?: boolean; + + @ApiProperty({ + description: 'Optional changelog to associate with the published version', + required: false, + example: 'Updated access controls section', + }) + @IsOptional() + @IsString() + changelog?: string; +} + +export class SubmitForApprovalDto { + @ApiProperty({ + description: 'Member ID of the approver', + example: 'mem_abc123def456', + }) + @IsString() + approverId: string; +} diff --git a/apps/api/src/policies/policies.controller.ts b/apps/api/src/policies/policies.controller.ts index 1020be850..2f27305fd 100644 --- a/apps/api/src/policies/policies.controller.ts +++ b/apps/api/src/policies/policies.controller.ts @@ -31,6 +31,12 @@ import type { AuthContext as AuthContextType } from '../auth/types'; import { CreatePolicyDto } from './dto/create-policy.dto'; import { UpdatePolicyDto } from './dto/update-policy.dto'; import { AISuggestPolicyRequestDto } from './dto/ai-suggest-policy.dto'; +import { + CreateVersionDto, + PublishVersionDto, + SubmitForApprovalDto, + UpdateVersionContentDto, +} from './dto/version.dto'; import { PoliciesService } from './policies.service'; import { GET_ALL_POLICIES_RESPONSES } from './schemas/get-all-policies.responses'; import { GET_POLICY_BY_ID_RESPONSES } from './schemas/get-policy-by-id.responses'; @@ -40,6 +46,18 @@ import { DELETE_POLICY_RESPONSES } from './schemas/delete-policy.responses'; import { POLICY_OPERATIONS } from './schemas/policy-operations'; import { POLICY_PARAMS } from './schemas/policy-params'; import { POLICY_BODIES } from './schemas/policy-bodies'; +import { VERSION_OPERATIONS } from './schemas/version-operations'; +import { VERSION_PARAMS } from './schemas/version-params'; +import { VERSION_BODIES } from './schemas/version-bodies'; +import { + CREATE_POLICY_VERSION_RESPONSES, + DELETE_VERSION_RESPONSES, + GET_POLICY_VERSIONS_RESPONSES, + PUBLISH_VERSION_RESPONSES, + SET_ACTIVE_VERSION_RESPONSES, + SUBMIT_VERSION_FOR_APPROVAL_RESPONSES, + UPDATE_VERSION_CONTENT_RESPONSES, +} from './schemas/version-responses'; import { PolicyResponseDto } from './dto/policy-responses.dto'; @ApiTags('Policies') @@ -222,6 +240,231 @@ export class PoliciesController { }; } + @Get(':id/versions') + @ApiOperation(VERSION_OPERATIONS.getPolicyVersions) + @ApiParam(VERSION_PARAMS.policyId) + @ApiResponse(GET_POLICY_VERSIONS_RESPONSES[200]) + @ApiResponse(GET_POLICY_VERSIONS_RESPONSES[401]) + @ApiResponse(GET_POLICY_VERSIONS_RESPONSES[404]) + async getPolicyVersions( + @Param('id') id: string, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + const data = await this.policiesService.getVersions(id, 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) + @ApiBody(VERSION_BODIES.createVersion) + @ApiResponse(CREATE_POLICY_VERSION_RESPONSES[201]) + @ApiResponse(CREATE_POLICY_VERSION_RESPONSES[400]) + @ApiResponse(CREATE_POLICY_VERSION_RESPONSES[401]) + @ApiResponse(CREATE_POLICY_VERSION_RESPONSES[404]) + async createPolicyVersion( + @Param('id') id: string, + @Body() body: CreateVersionDto, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + const data = await this.policiesService.createVersion( + id, + organizationId, + body, + authContext.userId, + ); + + return { + data, + authType: authContext.authType, + ...(authContext.userId && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), + }; + } + + @Patch(':id/versions/:versionId') + @ApiOperation(VERSION_OPERATIONS.updateVersionContent) + @ApiParam(VERSION_PARAMS.policyId) + @ApiParam(VERSION_PARAMS.versionId) + @ApiBody(VERSION_BODIES.updateVersionContent) + @ApiResponse(UPDATE_VERSION_CONTENT_RESPONSES[200]) + @ApiResponse(UPDATE_VERSION_CONTENT_RESPONSES[400]) + @ApiResponse(UPDATE_VERSION_CONTENT_RESPONSES[401]) + @ApiResponse(UPDATE_VERSION_CONTENT_RESPONSES[404]) + async updateVersionContent( + @Param('id') id: string, + @Param('versionId') versionId: string, + @Body() body: UpdateVersionContentDto, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + const data = await this.policiesService.updateVersionContent( + id, + versionId, + organizationId, + body, + ); + + return { + data, + authType: authContext.authType, + ...(authContext.userId && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), + }; + } + + @Delete(':id/versions/:versionId') + @ApiOperation(VERSION_OPERATIONS.deletePolicyVersion) + @ApiParam(VERSION_PARAMS.policyId) + @ApiParam(VERSION_PARAMS.versionId) + @ApiResponse(DELETE_VERSION_RESPONSES[200]) + @ApiResponse(DELETE_VERSION_RESPONSES[400]) + @ApiResponse(DELETE_VERSION_RESPONSES[401]) + @ApiResponse(DELETE_VERSION_RESPONSES[404]) + async deletePolicyVersion( + @Param('id') id: string, + @Param('versionId') versionId: string, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + const data = await this.policiesService.deleteVersion( + id, + versionId, + organizationId, + ); + + return { + data, + authType: authContext.authType, + ...(authContext.userId && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), + }; + } + + @Post(':id/versions/publish') + @ApiOperation(VERSION_OPERATIONS.publishPolicyVersion) + @ApiParam(VERSION_PARAMS.policyId) + @ApiBody(VERSION_BODIES.publishVersion) + @ApiResponse(PUBLISH_VERSION_RESPONSES[200]) + @ApiResponse(PUBLISH_VERSION_RESPONSES[400]) + @ApiResponse(PUBLISH_VERSION_RESPONSES[401]) + @ApiResponse(PUBLISH_VERSION_RESPONSES[404]) + async publishPolicyVersion( + @Param('id') id: string, + @Body() body: PublishVersionDto, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + const data = await this.policiesService.publishVersion( + id, + organizationId, + body, + authContext.userId, + ); + + return { + data, + authType: authContext.authType, + ...(authContext.userId && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), + }; + } + + @Post(':id/versions/:versionId/activate') + @ApiOperation(VERSION_OPERATIONS.setActivePolicyVersion) + @ApiParam(VERSION_PARAMS.policyId) + @ApiParam(VERSION_PARAMS.versionId) + @ApiResponse(SET_ACTIVE_VERSION_RESPONSES[200]) + @ApiResponse(SET_ACTIVE_VERSION_RESPONSES[400]) + @ApiResponse(SET_ACTIVE_VERSION_RESPONSES[401]) + @ApiResponse(SET_ACTIVE_VERSION_RESPONSES[404]) + async setActivePolicyVersion( + @Param('id') id: string, + @Param('versionId') versionId: string, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + const data = await this.policiesService.setActiveVersion( + id, + versionId, + organizationId, + ); + + return { + data, + authType: authContext.authType, + ...(authContext.userId && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), + }; + } + + @Post(':id/versions/:versionId/submit-for-approval') + @ApiOperation(VERSION_OPERATIONS.submitVersionForApproval) + @ApiParam(VERSION_PARAMS.policyId) + @ApiParam(VERSION_PARAMS.versionId) + @ApiBody(VERSION_BODIES.submitForApproval) + @ApiResponse(SUBMIT_VERSION_FOR_APPROVAL_RESPONSES[200]) + @ApiResponse(SUBMIT_VERSION_FOR_APPROVAL_RESPONSES[400]) + @ApiResponse(SUBMIT_VERSION_FOR_APPROVAL_RESPONSES[401]) + @ApiResponse(SUBMIT_VERSION_FOR_APPROVAL_RESPONSES[404]) + async submitVersionForApproval( + @Param('id') id: string, + @Param('versionId') versionId: string, + @Body() body: SubmitForApprovalDto, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + const data = await this.policiesService.submitForApproval( + id, + versionId, + organizationId, + body, + ); + + return { + data, + authType: authContext.authType, + ...(authContext.userId && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), + }; + } + @Post(':id/ai-chat') @ApiOperation({ summary: 'Chat with AI about a policy', @@ -256,7 +499,9 @@ export class PoliciesController { const policy = await this.policiesService.findById(id, organizationId); - const policyContentText = this.convertPolicyContentToText(policy.content); + // Use currentVersion content if available, fallback to policy.content for backward compatibility + const effectiveContent = policy.currentVersion?.content ?? policy.content; + const policyContentText = this.convertPolicyContentToText(effectiveContent); const systemPrompt = `You are an expert GRC (Governance, Risk, and Compliance) policy editor. You help users edit and improve their organizational policies to meet compliance requirements like SOC 2, ISO 27001, and GDPR. diff --git a/apps/api/src/policies/policies.service.ts b/apps/api/src/policies/policies.service.ts index b8edc1eba..a39cc74e9 100644 --- a/apps/api/src/policies/policies.service.ts +++ b/apps/api/src/policies/policies.service.ts @@ -1,15 +1,26 @@ -import { Injectable, NotFoundException, Logger } from '@nestjs/common'; -import { db } from '@trycompai/db'; -import type { Prisma } from '@trycompai/db'; +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { db, PolicyStatus, Prisma } from '@trycompai/db'; import { PDFDocument, rgb, StandardFonts } from 'pdf-lib'; import { AttachmentsService } from '../attachments/attachments.service'; import { PolicyPdfRendererService } from '../trust-portal/policy-pdf-renderer.service'; import type { CreatePolicyDto } from './dto/create-policy.dto'; import type { UpdatePolicyDto } from './dto/update-policy.dto'; +import type { + CreateVersionDto, + PublishVersionDto, + SubmitForApprovalDto, + UpdateVersionContentDto, +} from './dto/version.dto'; @Injectable() export class PoliciesService { private readonly logger = new Logger(PoliciesService.name); + private readonly versionCreateRetries = 3; constructor( private readonly attachmentsService: AttachmentsService, @@ -26,6 +37,7 @@ export class PoliciesService { description: true, status: true, content: true, + draftContent: true, frequency: true, department: true, isRequiredToSign: true, @@ -40,6 +52,10 @@ export class PoliciesService { assigneeId: true, approverId: true, policyTemplateId: true, + currentVersionId: true, + pendingVersionId: true, + displayFormat: true, + pdfUrl: true, assignee: { select: { id: true, @@ -80,6 +96,7 @@ export class PoliciesService { description: true, status: true, content: true, + draftContent: true, frequency: true, department: true, isRequiredToSign: true, @@ -94,6 +111,23 @@ export class PoliciesService { assigneeId: true, approverId: true, policyTemplateId: true, + currentVersionId: true, + pendingVersionId: true, + displayFormat: true, + pdfUrl: true, + approver: { + include: { + user: true, + }, + }, + currentVersion: { + select: { + id: true, + content: true, + pdfUrl: true, + version: true, + }, + }, }, }); @@ -114,36 +148,61 @@ export class PoliciesService { async create(organizationId: string, createData: CreatePolicyDto) { try { - const policy = await db.policy.create({ - data: { - ...createData, - // Ensure JSON[] type compatibility for Prisma - content: createData.content as Prisma.InputJsonValue[], - organizationId, - status: createData.status || 'draft', - isRequiredToSign: createData.isRequiredToSign ?? true, - }, - 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, - }, + const contentValue = createData.content as Prisma.InputJsonValue[]; + + // Create policy with version 1 in a transaction + const policy = await db.$transaction(async (tx) => { + // Create the policy first (without currentVersionId) + const newPolicy = await tx.policy.create({ + data: { + ...createData, + // Ensure JSON[] type compatibility for Prisma + content: contentValue, + organizationId, + status: createData.status || 'draft', + isRequiredToSign: createData.isRequiredToSign ?? true, + }, + }); + + // Create version 1 as a draft + const version = await tx.policyVersion.create({ + data: { + policyId: newPolicy.id, + version: 1, + content: contentValue, + changelog: 'Initial version', + }, + }); + + // Update policy to set currentVersionId + const updatedPolicy = await tx.policy.update({ + where: { id: newPolicy.id }, + data: { currentVersionId: version.id }, + 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, + }, + }); + + return updatedPolicy; }); this.logger.log(`Created policy: ${policy.name} (${policy.id})`); @@ -235,6 +294,7 @@ export class PoliciesService { async deleteById(id: string, organizationId: string) { try { // First check if the policy exists and belongs to the organization + // Include versions to clean up their PDFs from S3 const policy = await db.policy.findFirst({ where: { id, @@ -243,6 +303,10 @@ export class PoliciesService { select: { id: true, name: true, + pdfUrl: true, + versions: { + select: { pdfUrl: true }, + }, }, }); @@ -250,7 +314,33 @@ export class PoliciesService { throw new NotFoundException(`Policy with ID ${id} not found`); } - // Delete the policy + // Clean up S3 files before cascade delete + const pdfUrlsToDelete: string[] = []; + + // Add policy-level PDF if exists + if (policy.pdfUrl) { + pdfUrlsToDelete.push(policy.pdfUrl); + } + + // Add all version PDFs + for (const version of policy.versions) { + if (version.pdfUrl) { + pdfUrlsToDelete.push(version.pdfUrl); + } + } + + // Delete all PDFs from S3 (don't fail if S3 delete fails) + if (pdfUrlsToDelete.length > 0) { + await Promise.allSettled( + pdfUrlsToDelete.map((pdfUrl) => + this.attachmentsService.deletePolicyVersionPdf(pdfUrl).catch((err) => { + this.logger.warn(`Failed to delete PDF from S3: ${pdfUrl}`, err); + }), + ), + ); + } + + // Delete the policy (versions are cascade deleted) await db.policy.delete({ where: { id }, }); @@ -266,6 +356,487 @@ export class PoliciesService { } } + async getVersions(policyId: 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 versions = await db.policyVersion.findMany({ + where: { policyId }, + orderBy: { version: 'desc' }, + include: { + publishedBy: { + include: { + user: { + select: { + id: true, + name: true, + image: true, + }, + }, + }, + }, + }, + }); + + return { + versions, + currentVersionId: policy.currentVersionId, + pendingVersionId: policy.pendingVersionId, + }; + } + + async createVersion( + policyId: string, + organizationId: string, + dto: CreateVersionDto, + userId?: string, + ) { + const memberId = await this.getMemberId(organizationId, userId); + + const policy = await db.policy.findUnique({ + where: { id: policyId, organizationId }, + include: { + currentVersion: true, + versions: { + orderBy: { version: 'desc' }, + take: 1, + }, + }, + }); + + if (!policy) { + throw new NotFoundException(`Policy with ID ${policyId} not found`); + } + + let sourceVersion = policy.currentVersion; + if (dto.sourceVersionId) { + const requestedVersion = await db.policyVersion.findUnique({ + where: { id: dto.sourceVersionId }, + }); + + if (!requestedVersion || requestedVersion.policyId !== policyId) { + throw new NotFoundException('Source version not found'); + } + + sourceVersion = requestedVersion; + } + + const contentForVersion = sourceVersion + ? (sourceVersion.content as Prisma.InputJsonValue[]) + : (policy.content as Prisma.InputJsonValue[]); + const sourcePdfUrl = sourceVersion?.pdfUrl ?? policy.pdfUrl; + + if (!contentForVersion || contentForVersion.length === 0) { + throw new BadRequestException('No content to create version from'); + } + + // S3 copy is done AFTER the transaction to prevent orphaned files on retry + let createdVersion: { versionId: string; version: number } | null = null; + + for (let attempt = 1; attempt <= this.versionCreateRetries; attempt += 1) { + try { + createdVersion = await db.$transaction(async (tx) => { + const latestVersion = await tx.policyVersion.findFirst({ + where: { policyId }, + orderBy: { version: 'desc' }, + select: { version: true }, + }); + const nextVersion = (latestVersion?.version ?? 0) + 1; + + // Create version WITHOUT PDF first (S3 copy happens after transaction) + const newVersion = await tx.policyVersion.create({ + data: { + policyId, + version: nextVersion, + content: contentForVersion, + pdfUrl: null, // Will be updated after S3 copy + publishedById: memberId, + changelog: dto.changelog ?? null, + }, + }); + + return { + versionId: newVersion.id, + version: nextVersion, + }; + }); + + // Transaction succeeded, break out of retry loop + break; + } catch (error) { + if ( + this.isUniqueConstraintError(error) && + attempt < this.versionCreateRetries + ) { + continue; + } + throw error; + } + } + + if (!createdVersion) { + throw new Error('Failed to create policy version after retries'); + } + + // Now copy S3 file OUTSIDE the transaction (no orphaned files on retry) + if (sourcePdfUrl) { + try { + const newS3Key = `${organizationId}/policies/${policyId}/v${createdVersion.version}-${Date.now()}.pdf`; + const newPdfUrl = await this.attachmentsService.copyPolicyVersionPdf( + sourcePdfUrl, + newS3Key, + ); + + if (newPdfUrl) { + // Update the version with the PDF URL + await db.policyVersion.update({ + where: { id: createdVersion.versionId }, + data: { pdfUrl: newPdfUrl }, + }); + } + } catch (error) { + // Log but don't fail - version was created successfully, just without PDF + this.logger.warn( + `Failed to copy PDF for new version ${createdVersion.versionId}:`, + error, + ); + } + } + + return createdVersion; + } + + async updateVersionContent( + policyId: string, + versionId: string, + organizationId: string, + dto: UpdateVersionContentDto, + ) { + const version = await db.policyVersion.findUnique({ + where: { id: versionId }, + include: { + policy: { + select: { + id: true, + organizationId: true, + currentVersionId: true, + pendingVersionId: true, + }, + }, + }, + }); + + if ( + !version || + version.policy.id !== policyId || + version.policy.organizationId !== organizationId + ) { + throw new NotFoundException('Version not found'); + } + + if (version.id === version.policy.currentVersionId) { + throw new BadRequestException( + 'Cannot edit the published version. Create a new version to make changes.', + ); + } + + if (version.id === version.policy.pendingVersionId) { + throw new BadRequestException( + 'Cannot edit a version that is pending approval.', + ); + } + + const processedContent = JSON.parse( + JSON.stringify(dto.content ?? []), + ) as Prisma.InputJsonValue[]; + + await db.policyVersion.update({ + where: { id: versionId }, + data: { content: processedContent }, + }); + + return { versionId }; + } + + async deleteVersion( + policyId: string, + versionId: string, + organizationId: string, + ) { + const policy = await db.policy.findUnique({ + 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 }, + select: { + id: true, + policyId: true, + pdfUrl: true, + version: true, + }, + }); + + if (!version || version.policyId !== policyId) { + throw new NotFoundException('Version not found'); + } + + if (version.id === policy.currentVersionId) { + throw new BadRequestException('Cannot delete the published version'); + } + + if (version.id === policy.pendingVersionId) { + throw new BadRequestException('Cannot delete a version pending approval'); + } + + if (version.pdfUrl) { + try { + await this.attachmentsService.deletePolicyVersionPdf(version.pdfUrl); + } catch (error) { + this.logger.warn( + `Failed to delete version PDF for version ${version.id}`, + error, + ); + } + } + + await db.policyVersion.delete({ + where: { id: versionId }, + }); + + return { deletedVersion: version.version }; + } + + async publishVersion( + policyId: string, + organizationId: string, + dto: PublishVersionDto, + userId?: string, + ) { + const memberId = await this.getMemberId(organizationId, userId); + + const policy = await db.policy.findUnique({ + where: { id: policyId, organizationId }, + include: { + versions: { + orderBy: { version: 'desc' }, + take: 1, + }, + }, + }); + + if (!policy) { + throw new NotFoundException(`Policy with ID ${policyId} not found`); + } + + const contentToPublish = ( + policy.draftContent && policy.draftContent.length > 0 + ? policy.draftContent + : policy.content + ) as Prisma.InputJsonValue[]; + + if (!contentToPublish || contentToPublish.length === 0) { + throw new BadRequestException('No content to publish'); + } + + for (let attempt = 1; attempt <= this.versionCreateRetries; attempt += 1) { + try { + return await db.$transaction(async (tx) => { + const latestVersion = await tx.policyVersion.findFirst({ + where: { policyId }, + orderBy: { version: 'desc' }, + select: { version: true }, + }); + const nextVersion = (latestVersion?.version ?? 0) + 1; + + const newVersion = await tx.policyVersion.create({ + data: { + policyId, + version: nextVersion, + content: contentToPublish, + pdfUrl: policy.pdfUrl, + publishedById: memberId, + changelog: dto.changelog ?? null, + }, + }); + + await tx.policy.update({ + where: { id: policyId }, + data: { + content: contentToPublish, + draftContent: contentToPublish, + lastPublishedAt: new Date(), + status: 'published', + // Clear any pending approval since we're publishing directly + pendingVersionId: null, + approverId: null, + // Clear signatures - employees must re-acknowledge new content + signedBy: [], + ...(dto.setAsActive !== false && { + currentVersionId: newVersion.id, + }), + }, + }); + + return { + versionId: newVersion.id, + version: nextVersion, + }; + }); + } catch (error) { + if ( + this.isUniqueConstraintError(error) && + attempt < this.versionCreateRetries + ) { + continue; + } + throw error; + } + } + + throw new Error('Failed to publish policy version after retries'); + } + + async setActiveVersion( + policyId: string, + versionId: string, + organizationId: string, + ) { + const policy = await db.policy.findUnique({ + where: { id: policyId, organizationId }, + }); + + if (!policy) { + throw new NotFoundException(`Policy with ID ${policyId} not found`); + } + + if (policy.pendingVersionId && policy.pendingVersionId !== versionId) { + throw new BadRequestException( + 'Another version is already pending approval', + ); + } + + const version = await db.policyVersion.findUnique({ + where: { id: versionId }, + }); + + if (!version || version.policyId !== policyId) { + throw new NotFoundException('Version not found'); + } + + await db.policy.update({ + where: { id: policyId }, + data: { + currentVersionId: versionId, + content: version.content as Prisma.InputJsonValue[], + draftContent: version.content as Prisma.InputJsonValue[], // Sync draft to prevent "unpublished changes" UI bug + status: 'published', + // Clear pending approval state since we're directly activating a version + pendingVersionId: null, + approverId: null, + // Clear signatures - employees must re-acknowledge new content + signedBy: [], + }, + }); + + return { + versionId: version.id, + version: version.version, + }; + } + + async submitForApproval( + policyId: string, + versionId: string, + organizationId: string, + dto: SubmitForApprovalDto, + ) { + const policy = await db.policy.findUnique({ + where: { id: policyId, organizationId }, + }); + + if (!policy) { + throw new NotFoundException(`Policy with ID ${policyId} not found`); + } + + const version = await db.policyVersion.findUnique({ + where: { id: versionId }, + }); + + if (!version || version.policyId !== policyId) { + throw new NotFoundException('Version not found'); + } + + // Cannot submit the already-active version for approval + if (versionId === policy.currentVersionId) { + throw new BadRequestException( + 'Cannot submit the currently published version for approval', + ); + } + + const approver = await db.member.findUnique({ + where: { id: dto.approverId }, + }); + + if (!approver || approver.organizationId !== organizationId) { + throw new NotFoundException('Approver not found'); + } + + // Cannot assign a deactivated member as approver - they can't log in to approve + if (approver.deactivated) { + throw new BadRequestException('Cannot assign a deactivated member as approver'); + } + + await db.policy.update({ + where: { id: policyId }, + data: { + pendingVersionId: versionId, + status: PolicyStatus.needs_review, + approverId: dto.approverId, + }, + }); + + return { + versionId: version.id, + version: version.version, + }; + } + + private async getMemberId( + organizationId: string, + userId?: string, + ): Promise { + if (!userId) { + return null; + } + + const member = await db.member.findFirst({ + where: { + userId, + organizationId, + deactivated: false, + }, + select: { id: true }, + }); + + return member?.id ?? null; + } + /** * Convert hex color to RGB values (0-1 range for pdf-lib) */ @@ -322,7 +893,7 @@ export class PoliciesService { throw new NotFoundException('Organization not found'); } - // Get all published policies + // Get all published policies with currentVersion const policies = await db.policy.findMany({ where: { organizationId, @@ -334,6 +905,12 @@ export class PoliciesService { name: true, content: true, pdfUrl: true, + currentVersion: { + select: { + content: true, + pdfUrl: true, + }, + }, }, orderBy: [{ lastPublishedAt: 'desc' }, { updatedAt: 'desc' }], }); @@ -359,15 +936,23 @@ export class PoliciesService { isUploaded: boolean; }; + // Helper to get effective content and pdfUrl (version first, fallback to policy) + const getEffectiveData = (policy: (typeof policies)[0]) => { + const content = policy.currentVersion?.content ?? policy.content; + const pdfUrl = policy.currentVersion?.pdfUrl ?? policy.pdfUrl; + return { content, pdfUrl }; + }; + const preparePolicy = async ( policy: (typeof policies)[0], ): Promise => { - const hasUploadedPdf = policy.pdfUrl && policy.pdfUrl.trim() !== ''; + const { content, pdfUrl } = getEffectiveData(policy); + const hasUploadedPdf = pdfUrl && pdfUrl.trim() !== ''; if (hasUploadedPdf) { try { const pdfBuffer = await this.attachmentsService.getObjectBuffer( - policy.pdfUrl!, + pdfUrl!, ); return { policy, @@ -384,7 +969,7 @@ export class PoliciesService { // Render from content (either no pdfUrl or fetch failed) const renderedBuffer = this.pdfRendererService.renderPoliciesPdfBuffer( - [{ name: policy.name, content: policy.content }], + [{ name: policy.name, content }], undefined, // We'll add org header during merge organization.primaryColor, policies.length, @@ -400,8 +985,9 @@ export class PoliciesService { policy: (typeof policies)[0], addOrgHeader: boolean, ) => { + const { content } = getEffectiveData(policy); const renderedBuffer = this.pdfRendererService.renderPoliciesPdfBuffer( - [{ name: policy.name, content: policy.content }], + [{ name: policy.name, content }], addOrgHeader ? organizationName : undefined, organization.primaryColor, policies.length, @@ -569,4 +1155,11 @@ export class PoliciesService { policyCount: policies.length, }; } + + private isUniqueConstraintError(error: unknown): boolean { + return ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + ); + } } diff --git a/apps/api/src/policies/schemas/version-bodies.ts b/apps/api/src/policies/schemas/version-bodies.ts new file mode 100644 index 000000000..b9c837c3d --- /dev/null +++ b/apps/api/src/policies/schemas/version-bodies.ts @@ -0,0 +1,26 @@ +import type { ApiBodyOptions } from '@nestjs/swagger'; +import { + CreateVersionDto, + PublishVersionDto, + SubmitForApprovalDto, + UpdateVersionContentDto, +} from '../dto/version.dto'; + +export const VERSION_BODIES: Record = { + createVersion: { + type: CreateVersionDto, + description: 'Create a new policy version draft', + }, + updateVersionContent: { + type: UpdateVersionContentDto, + description: 'Update content for a policy version', + }, + publishVersion: { + type: PublishVersionDto, + description: 'Publish a new policy version', + }, + submitForApproval: { + type: SubmitForApprovalDto, + description: 'Submit a policy version for approval', + }, +}; diff --git a/apps/api/src/policies/schemas/version-operations.ts b/apps/api/src/policies/schemas/version-operations.ts new file mode 100644 index 000000000..a8c52e629 --- /dev/null +++ b/apps/api/src/policies/schemas/version-operations.ts @@ -0,0 +1,39 @@ +import type { ApiOperationOptions } from '@nestjs/swagger'; + +export const VERSION_OPERATIONS: Record = { + getPolicyVersions: { + summary: 'Get policy versions', + description: + 'Returns all versions for a policy in descending order. Supports both API key authentication and session authentication.', + }, + createPolicyVersion: { + summary: 'Create policy version', + description: + 'Creates a new draft version based on the current published version (or a specified source version).', + }, + updateVersionContent: { + summary: 'Update version content', + description: + 'Updates content for a non-published, non-pending version. Published and pending versions are immutable.', + }, + deletePolicyVersion: { + summary: 'Delete policy version', + description: + 'Deletes a non-published, non-pending version. Published and pending versions cannot be deleted.', + }, + publishPolicyVersion: { + summary: 'Publish new policy version', + description: + 'Publishes draft content as a new version and optionally sets it as active.', + }, + setActivePolicyVersion: { + summary: 'Set active policy version', + description: + 'Marks a version as the active (published) version and updates the policy content.', + }, + submitVersionForApproval: { + summary: 'Submit version for approval', + description: + 'Submits a version for approval by setting pendingVersionId and updating policy status.', + }, +}; diff --git a/apps/api/src/policies/schemas/version-params.ts b/apps/api/src/policies/schemas/version-params.ts new file mode 100644 index 000000000..df33c7019 --- /dev/null +++ b/apps/api/src/policies/schemas/version-params.ts @@ -0,0 +1,16 @@ +import type { ApiParamOptions } from '@nestjs/swagger'; + +export const VERSION_PARAMS: Record = { + policyId: { + name: 'id', + description: 'Policy ID', + required: true, + schema: { type: 'string', example: 'pol_abc123def456' }, + }, + versionId: { + name: 'versionId', + description: 'Policy version ID', + required: true, + schema: { type: 'string', example: 'pv_abc123def456' }, + }, +}; diff --git a/apps/api/src/policies/schemas/version-responses.ts b/apps/api/src/policies/schemas/version-responses.ts new file mode 100644 index 000000000..13cc45069 --- /dev/null +++ b/apps/api/src/policies/schemas/version-responses.ts @@ -0,0 +1,193 @@ +import type { ApiResponseOptions } from '@nestjs/swagger'; + +const UNAUTHORIZED_RESPONSE: ApiResponseOptions = { + status: 401, + description: 'Unauthorized', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { message: { type: 'string', example: 'Unauthorized' } }, + }, + }, + }, +}; + +const NOT_FOUND_RESPONSE: ApiResponseOptions = { + status: 404, + description: 'Resource not found', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + message: { type: 'string', example: 'Resource not found' }, + }, + }, + }, + }, +}; + +const BAD_REQUEST_RESPONSE: ApiResponseOptions = { + status: 400, + description: 'Invalid request', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + message: { type: 'string', example: 'Invalid request' }, + }, + }, + }, + }, +}; + +export const GET_POLICY_VERSIONS_RESPONSES: Record = + { + 200: { + status: 200, + description: 'Policy versions retrieved successfully', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + versions: { type: 'array', items: { type: 'object' } }, + currentVersionId: { type: 'string', nullable: true }, + pendingVersionId: { type: 'string', nullable: true }, + }, + }, + }, + }, + }, + 401: UNAUTHORIZED_RESPONSE, + 404: NOT_FOUND_RESPONSE, + }; + +export const CREATE_POLICY_VERSION_RESPONSES: Record = + { + 201: { + status: 201, + description: 'Policy version created', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + versionId: { type: 'string' }, + version: { type: 'number' }, + }, + }, + }, + }, + }, + 400: BAD_REQUEST_RESPONSE, + 401: UNAUTHORIZED_RESPONSE, + 404: NOT_FOUND_RESPONSE, + }; + +export const UPDATE_VERSION_CONTENT_RESPONSES: Record< + string, + ApiResponseOptions +> = { + 200: { + status: 200, + description: 'Version content updated', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { versionId: { type: 'string' } }, + }, + }, + }, + }, + 400: BAD_REQUEST_RESPONSE, + 401: UNAUTHORIZED_RESPONSE, + 404: NOT_FOUND_RESPONSE, +}; + +export const DELETE_VERSION_RESPONSES: Record = { + 200: { + status: 200, + description: 'Version deleted', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { deletedVersion: { type: 'number' } }, + }, + }, + }, + }, + 400: BAD_REQUEST_RESPONSE, + 401: UNAUTHORIZED_RESPONSE, + 404: NOT_FOUND_RESPONSE, +}; + +export const PUBLISH_VERSION_RESPONSES: Record = { + 200: { + status: 200, + description: 'Version published', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + versionId: { type: 'string' }, + version: { type: 'number' }, + }, + }, + }, + }, + }, + 400: BAD_REQUEST_RESPONSE, + 401: UNAUTHORIZED_RESPONSE, + 404: NOT_FOUND_RESPONSE, +}; + +export const SET_ACTIVE_VERSION_RESPONSES: Record = { + 200: { + status: 200, + description: 'Active version updated', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + versionId: { type: 'string' }, + version: { type: 'number' }, + }, + }, + }, + }, + }, + 400: BAD_REQUEST_RESPONSE, + 401: UNAUTHORIZED_RESPONSE, + 404: NOT_FOUND_RESPONSE, +}; + +export const SUBMIT_VERSION_FOR_APPROVAL_RESPONSES: Record< + string, + ApiResponseOptions +> = { + 200: { + status: 200, + description: 'Version submitted for approval', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + versionId: { type: 'string' }, + version: { type: 'number' }, + }, + }, + }, + }, + }, + 400: BAD_REQUEST_RESPONSE, + 401: UNAUTHORIZED_RESPONSE, + 404: NOT_FOUND_RESPONSE, +}; diff --git a/apps/api/src/trigger/tasks/onboarding/migrate-policies-for-all-orgs.ts b/apps/api/src/trigger/tasks/onboarding/migrate-policies-for-all-orgs.ts new file mode 100644 index 000000000..2738bafc9 --- /dev/null +++ b/apps/api/src/trigger/tasks/onboarding/migrate-policies-for-all-orgs.ts @@ -0,0 +1,139 @@ +import { db } from '@db'; +import { logger, task } from '@trigger.dev/sdk'; +import { migratePoliciesForOrg } from './migrate-policies-for-org'; + +const ORG_BATCH_SIZE = 20; + +export const migratePoliciesForAllOrgs = task({ + id: 'migrate-policies-for-all-orgs', + run: async () => { + // Count total legacy policies + const totalLegacyPolicies = await db.policy.count({ + where: { + versions: { none: {} }, + }, + }); + + logger.info(`Total legacy policies across all orgs: ${totalLegacyPolicies}`); + + if (totalLegacyPolicies === 0) { + return { + totalOrgs: 0, + totalPolicies: 0, + successful: 0, + failed: 0, + message: 'No policies need migration', + }; + } + + // Find orgs with legacy policies, ordered by most recent policy activity + const orgsWithActivity = await db.policy.groupBy({ + by: ['organizationId'], + where: { + versions: { none: {} }, + }, + _max: { + updatedAt: true, + }, + orderBy: { + _max: { + updatedAt: 'desc', + }, + }, + }); + + if (orgsWithActivity.length === 0) { + return { + totalOrgs: 0, + totalPolicies: totalLegacyPolicies, + successful: 0, + failed: 0, + message: 'No organizations need migration', + }; + } + + // Get org details + const orgsWithLegacyPolicies = await db.organization.findMany({ + where: { + id: { in: orgsWithActivity.map((org) => org.organizationId) }, + }, + select: { + id: true, + name: true, + }, + }); + + // Sort orgs by the activity order from groupBy + const orgActivityOrder = new Map( + orgsWithActivity.map((org, index) => [org.organizationId, index]), + ); + orgsWithLegacyPolicies.sort( + (a, b) => + (orgActivityOrder.get(a.id) ?? 0) - (orgActivityOrder.get(b.id) ?? 0), + ); + + logger.info( + `Found ${orgsWithLegacyPolicies.length} orgs with legacy policies (most active first)`, + ); + + if (orgsWithLegacyPolicies.length === 0) { + return { + totalOrgs: 0, + totalPolicies: totalLegacyPolicies, + successful: 0, + failed: 0, + message: 'No organizations need migration', + }; + } + + // Process orgs in batches to avoid overwhelming the system + let totalSuccessful = 0; + let totalFailed = 0; + + for (let i = 0; i < orgsWithLegacyPolicies.length; i += ORG_BATCH_SIZE) { + const batch = orgsWithLegacyPolicies.slice(i, i + ORG_BATCH_SIZE); + const batchNumber = Math.floor(i / ORG_BATCH_SIZE) + 1; + const totalBatches = Math.ceil( + orgsWithLegacyPolicies.length / ORG_BATCH_SIZE, + ); + + logger.info( + `Processing org batch ${batchNumber}/${totalBatches} (${batch.length} orgs)`, + ); + + try { + const batchResult = await migratePoliciesForOrg.batchTriggerAndWait( + batch.map((org) => ({ + payload: { organizationId: org.id }, + })), + ); + + const successful = batchResult.runs.filter((run) => run.ok).length; + const failed = batchResult.runs.filter((run) => !run.ok).length; + + totalSuccessful += successful; + totalFailed += failed; + + logger.info( + `Batch ${batchNumber} complete: ${successful} successful, ${failed} failed`, + ); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + logger.error(`Batch ${batchNumber} failed entirely: ${errorMsg}`); + totalFailed += batch.length; + } + } + + logger.info( + `Migration complete: ${totalSuccessful} orgs successful, ${totalFailed} orgs failed`, + ); + + return { + totalOrgs: orgsWithLegacyPolicies.length, + totalPolicies: totalLegacyPolicies, + successful: totalSuccessful, + failed: totalFailed, + message: `Migrated policies for ${totalSuccessful}/${orgsWithLegacyPolicies.length} organizations`, + }; + }, +}); diff --git a/apps/api/src/trigger/tasks/onboarding/migrate-policies-for-org.ts b/apps/api/src/trigger/tasks/onboarding/migrate-policies-for-org.ts new file mode 100644 index 000000000..3f2d16a3a --- /dev/null +++ b/apps/api/src/trigger/tasks/onboarding/migrate-policies-for-org.ts @@ -0,0 +1,133 @@ +import { db, type Prisma } from '@db'; +import { logger, schemaTask } from '@trigger.dev/sdk'; +import { z } from 'zod'; + +const POLICY_BATCH_SIZE = 50; + +export const migratePoliciesForOrg = schemaTask({ + id: 'migrate-policies-for-org', + schema: z.object({ + organizationId: z.string(), + }), + run: async ({ organizationId }) => { + // Find policies without any versions + const policiesWithoutVersions = await db.policy.findMany({ + where: { + organizationId, + versions: { none: {} }, + }, + select: { + id: true, + content: true, + pdfUrl: true, + }, + }); + + if (policiesWithoutVersions.length === 0) { + logger.info(`No policies need migration for org ${organizationId}`); + return { + organizationId, + migratedCount: 0, + totalPolicies: 0, + skipped: true, + }; + } + + logger.info( + `Found ${policiesWithoutVersions.length} policies to migrate for org ${organizationId}`, + ); + + let totalMigrated = 0; + let totalSkipped = 0; + const errors: string[] = []; + const totalBatches = Math.ceil( + policiesWithoutVersions.length / POLICY_BATCH_SIZE, + ); + + for ( + let i = 0; + i < policiesWithoutVersions.length; + i += POLICY_BATCH_SIZE + ) { + const batch = policiesWithoutVersions.slice(i, i + POLICY_BATCH_SIZE); + const batchNumber = Math.floor(i / POLICY_BATCH_SIZE) + 1; + + try { + const result = await db.$transaction( + async (tx) => { + let migrated = 0; + let skipped = 0; + + for (const policy of batch) { + // Double-check: policy might have been migrated by lazy migration + const existingVersion = await tx.policyVersion.findFirst({ + where: { policyId: policy.id }, + select: { id: true }, + }); + + if (existingVersion) { + // Fix orphaned state if needed + await tx.policy.updateMany({ + where: { id: policy.id, currentVersionId: null }, + data: { currentVersionId: existingVersion.id }, + }); + skipped++; + continue; + } + + // Create version 1 + const version = await tx.policyVersion.create({ + data: { + policyId: policy.id, + version: 1, + content: (policy.content as Prisma.InputJsonValue[]) || [], + pdfUrl: policy.pdfUrl, + changelog: 'Migrated from legacy policy (bulk)', + }, + }); + + await tx.policy.update({ + where: { id: policy.id }, + data: { currentVersionId: version.id }, + }); + + migrated++; + } + + return { migrated, skipped }; + }, + { timeout: 30000 }, + ); + + totalMigrated += result.migrated; + totalSkipped += result.skipped; + + if (totalBatches > 1) { + logger.info( + `Batch ${batchNumber}/${totalBatches}: ${result.migrated} migrated, ${result.skipped} skipped`, + ); + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + logger.error( + `Batch ${batchNumber}/${totalBatches} failed for org ${organizationId}: ${errorMsg}`, + ); + errors.push(`Batch ${batchNumber}: ${errorMsg}`); + // Continue with next batch + } + } + + const status = errors.length > 0 ? 'completed with errors' : 'completed'; + logger.info( + `Org ${organizationId} ${status}: ${totalMigrated} migrated, ${totalSkipped} skipped`, + ); + + return { + organizationId, + migratedCount: totalMigrated, + skippedCount: totalSkipped, + totalPolicies: policiesWithoutVersions.length, + errors: errors.length > 0 ? errors : undefined, + }; + }, +}); diff --git a/apps/api/src/trust-portal/trust-access.service.ts b/apps/api/src/trust-portal/trust-access.service.ts index 01f12a25f..4d4ca8301 100644 --- a/apps/api/src/trust-portal/trust-access.service.ts +++ b/apps/api/src/trust-portal/trust-access.service.ts @@ -1530,6 +1530,12 @@ export class TrustAccessService { description: true, lastPublishedAt: true, updatedAt: true, + currentVersion: { + select: { + id: true, + version: true, + }, + }, }, orderBy: [{ lastPublishedAt: 'desc' }, { updatedAt: 'desc' }], }); @@ -1957,6 +1963,12 @@ export class TrustAccessService { name: true, content: true, pdfUrl: true, + currentVersion: { + select: { + content: true, + pdfUrl: true, + }, + }, }, orderBy: [{ lastPublishedAt: 'desc' }, { updatedAt: 'desc' }], }); @@ -1989,15 +2001,23 @@ export class TrustAccessService { isUploaded: boolean; }; + // Helper to get effective content and pdfUrl (version first, fallback to policy) + const getEffectiveData = (policy: (typeof policies)[0]) => { + const content = policy.currentVersion?.content ?? policy.content; + const pdfUrl = policy.currentVersion?.pdfUrl ?? policy.pdfUrl; + return { content, pdfUrl }; + }; + const preparePolicy = async ( policy: (typeof policies)[0], ): Promise => { - const hasUploadedPdf = policy.pdfUrl && policy.pdfUrl.trim() !== ''; + const { content, pdfUrl } = getEffectiveData(policy); + const hasUploadedPdf = pdfUrl && pdfUrl.trim() !== ''; if (hasUploadedPdf) { try { const pdfBuffer = await this.attachmentsService.getObjectBuffer( - policy.pdfUrl!, + pdfUrl!, ); return { policy, @@ -2014,7 +2034,7 @@ export class TrustAccessService { // Render from content (either no pdfUrl or fetch failed) const renderedBuffer = this.pdfRendererService.renderPoliciesPdfBuffer( - [{ name: policy.name, content: policy.content }], + [{ name: policy.name, content }], undefined, // We'll add org header during merge grant.accessRequest.organization.primaryColor, policies.length, @@ -2030,8 +2050,9 @@ export class TrustAccessService { policy: (typeof policies)[0], addOrgHeader: boolean, ) => { + const { content } = getEffectiveData(policy); const renderedBuffer = this.pdfRendererService.renderPoliciesPdfBuffer( - [{ name: policy.name, content: policy.content }], + [{ name: policy.name, content }], addOrgHeader ? organizationName : undefined, grant.accessRequest.organization.primaryColor, policies.length, @@ -2231,6 +2252,12 @@ export class TrustAccessService { name: true, content: true, pdfUrl: true, + currentVersion: { + select: { + content: true, + pdfUrl: true, + }, + }, }, orderBy: [{ lastPublishedAt: 'desc' }, { updatedAt: 'desc' }], }); @@ -2271,13 +2298,16 @@ export class TrustAccessService { // Process policies sequentially for (const policy of policies) { - const hasUploadedPdf = policy.pdfUrl && policy.pdfUrl.trim() !== ''; + // Use currentVersion content/pdfUrl with fallback to policy level + const effectiveContent = policy.currentVersion?.content ?? policy.content; + const effectivePdfUrl = policy.currentVersion?.pdfUrl ?? policy.pdfUrl; + const hasUploadedPdf = effectivePdfUrl && effectivePdfUrl.trim() !== ''; let policyPdfBuffer: Buffer; if (hasUploadedPdf) { try { const rawBuffer = await this.attachmentsService.getObjectBuffer( - policy.pdfUrl!, + effectivePdfUrl!, ); policyPdfBuffer = Buffer.from(rawBuffer); } catch (error) { @@ -2286,14 +2316,14 @@ export class TrustAccessService { error, ); policyPdfBuffer = this.pdfRendererService.renderPoliciesPdfBuffer( - [{ name: policy.name, content: policy.content }], + [{ name: policy.name, content: effectiveContent }], undefined, grant.accessRequest.organization.primaryColor, ); } } else { policyPdfBuffer = this.pdfRendererService.renderPoliciesPdfBuffer( - [{ name: policy.name, content: policy.content }], + [{ name: policy.name, content: effectiveContent }], undefined, grant.accessRequest.organization.primaryColor, ); diff --git a/apps/api/src/vector-store/lib/sync/sync-policies.ts b/apps/api/src/vector-store/lib/sync/sync-policies.ts index 25205f9db..b81b85fe5 100644 --- a/apps/api/src/vector-store/lib/sync/sync-policies.ts +++ b/apps/api/src/vector-store/lib/sync/sync-policies.ts @@ -24,11 +24,12 @@ interface PolicyData { /** * Fetch all published policies for an organization + * Uses currentVersion.content for versioned policies, falls back to policy.content */ export async function fetchPolicies( organizationId: string, ): Promise { - return db.policy.findMany({ + const policies = await db.policy.findMany({ where: { organizationId, status: 'published', @@ -40,8 +41,23 @@ export async function fetchPolicies( content: true, organizationId: true, updatedAt: true, + currentVersion: { + select: { + content: true, + }, + }, }, }); + + // Map to use currentVersion.content when available + return policies.map((policy) => ({ + id: policy.id, + name: policy.name, + description: policy.description, + content: policy.currentVersion?.content ?? policy.content, + organizationId: policy.organizationId, + updatedAt: policy.updatedAt, + })); } interface SyncSingleResult { diff --git a/apps/app/src/actions/organization/lib/initialize-organization.ts b/apps/app/src/actions/organization/lib/initialize-organization.ts index ddea1607e..37bf57df4 100644 --- a/apps/app/src/actions/organization/lib/initialize-organization.ts +++ b/apps/app/src/actions/organization/lib/initialize-organization.ts @@ -207,6 +207,46 @@ export const _upsertOrgFrameworkStructureCore = async ({ policyTemplateId: policyTemplate.id, })), }); + + // Fetch newly created policies to create versions for them + const newlyCreatedPolicies = await tx.policy.findMany({ + where: { + organizationId: organizationId, + policyTemplateId: { + in: policyTemplatesForCreation.map((t) => t.id), + }, + }, + select: { id: true, policyTemplateId: true, content: true }, + }); + + // Create version 1 for each newly created policy + if (newlyCreatedPolicies.length > 0) { + await tx.policyVersion.createMany({ + data: newlyCreatedPolicies.map((policy) => ({ + policyId: policy.id, + version: 1, + content: policy.content as Prisma.InputJsonValue[], + changelog: 'Initial version from template', + })), + }); + + // Fetch the created versions to update policies with currentVersionId + const createdVersions = await tx.policyVersion.findMany({ + where: { + policyId: { in: newlyCreatedPolicies.map((p) => p.id) }, + version: 1, + }, + select: { id: true, policyId: true }, + }); + + // Update each policy with its currentVersionId + for (const version of createdVersions) { + await tx.policy.update({ + where: { id: version.policyId }, + data: { currentVersionId: version.id }, + }); + } + } } /** diff --git a/apps/app/src/actions/policies/accept-requested-policy-changes.ts b/apps/app/src/actions/policies/accept-requested-policy-changes.ts index a1d271bde..160233dba 100644 --- a/apps/app/src/actions/policies/accept-requested-policy-changes.ts +++ b/apps/app/src/actions/policies/accept-requested-policy-changes.ts @@ -1,7 +1,7 @@ 'use server'; import { sendNewPolicyEmail } from '@/trigger/tasks/email/new-policy-email'; -import { db, PolicyStatus } from '@db'; +import { db, PolicyStatus, type Prisma } from '@db'; import { tasks } from '@trigger.dev/sdk'; import { revalidatePath, revalidateTag } from 'next/cache'; import { z } from 'zod'; @@ -62,19 +62,42 @@ export const acceptRequestedPolicyChangesAction = authActionClient // Check if there were previous signers to determine notification type const isNewPolicy = policy.lastPublishedAt === null; - // Update policy status and clear signedBy field + // Build update data + const updateData: Prisma.PolicyUpdateInput = { + status: PolicyStatus.published, + approver: { disconnect: true }, // Clear the approver relation + signedBy: [], // Clear the signedBy field + lastPublishedAt: new Date(), // Update last published date + reviewDate: new Date(), // Update reviewDate to current date + pendingVersionId: null, // Clear pending version + }; + + // If there's a pending version, make it the current version + if (policy.pendingVersionId) { + const pendingVersion = await db.policyVersion.findUnique({ + where: { id: policy.pendingVersionId }, + }); + + if (!pendingVersion || pendingVersion.policyId !== policy.id) { + // Pending version is missing or invalid - cannot proceed with approval + return { + success: false, + error: 'The pending version no longer exists. Approval cannot be completed.', + }; + } + + updateData.currentVersion = { connect: { id: pendingVersion.id } }; + updateData.content = pendingVersion.content as Prisma.InputJsonValue[]; + updateData.draftContent = pendingVersion.content as Prisma.InputJsonValue[]; + } + + // Update policy status and apply version changes await db.policy.update({ where: { id, organizationId: session.activeOrganizationId, }, - data: { - status: PolicyStatus.published, - approverId: null, - signedBy: [], // Clear the signedBy field - lastPublishedAt: new Date(), // Update last published date - reviewDate: new Date(), // Update reviewDate to current date - }, + data: updateData, }); // Get all employees in the organization to send notifications diff --git a/apps/app/src/actions/policies/create-new-policy.ts b/apps/app/src/actions/policies/create-new-policy.ts index 64cca38ea..91219d18f 100644 --- a/apps/app/src/actions/policies/create-new-policy.ts +++ b/apps/app/src/actions/policies/create-new-policy.ts @@ -1,6 +1,6 @@ 'use server'; -import { db, Departments, Frequency } from '@db'; +import { db, Departments, Frequency, PolicyStatus, type Prisma } from '@db'; import { revalidatePath, revalidateTag } from 'next/cache'; import { authActionClient } from '../safe-action'; import { createPolicySchema } from '../schema'; @@ -51,60 +51,56 @@ export const createPolicyAction = authActionClient } try { - // Create the policy - const policy = await db.policy.create({ - data: { - name: title, - description, - organizationId: activeOrganizationId, - assigneeId: member.id, - department: Departments.none, - frequency: Frequency.monthly, - content: [ - { - type: 'paragraph', - content: [{ type: 'text', text: '' }], - }, - ], - ...(controlIds && - controlIds.length > 0 && { - controls: { - connect: controlIds.map((id) => ({ id })), - }, - }), + const initialContent = [ + { + type: 'paragraph', + content: [{ type: 'text', text: '' }], }, - }); + ] as Prisma.InputJsonValue[]; - // Create artifacts for each control - // if (controlIds && controlIds.length > 0) { - // // Create artifacts that link the policy to controls - // await Promise.all( - // controlIds.map(async (controlId) => { - // // Create the artifact - // const artifact = await db.artifact.create({ - // data: { - // type: "policy", - // policyId: policy.id, - // organizationId: activeOrganizationId, - // }, - // }); + // Create the policy with version 1 in a transaction + const policy = await db.$transaction(async (tx) => { + // Create the policy first (without currentVersionId) + const newPolicy = await tx.policy.create({ + data: { + name: title, + description, + organizationId: activeOrganizationId, + assigneeId: member.id, + department: Departments.none, + frequency: Frequency.monthly, + status: PolicyStatus.draft, + content: initialContent, + draftContent: initialContent, // Sync with content to prevent false "unpublished changes" indicator + ...(controlIds && + controlIds.length > 0 && { + controls: { + connect: controlIds.map((id) => ({ id })), + }, + }), + }, + }); - // // Connect the artifact to the control - // await db.control.update({ - // where: { id: controlId }, - // data: { - // artifacts: { - // connect: { id: artifact.id }, - // }, - // }, - // }); + // Create version 1 as a draft + const version = await tx.policyVersion.create({ + data: { + policyId: newPolicy.id, + version: 1, + content: initialContent, + publishedById: member.id, + changelog: 'Initial version', + }, + }); - // return artifact; - // }), - // ); - // } + // Update policy to set currentVersionId + const updatedPolicy = await tx.policy.update({ + where: { id: newPolicy.id }, + data: { currentVersionId: version.id }, + }); + + return updatedPolicy; + }); - revalidatePath(`/${activeOrganizationId}/policies`); revalidatePath(`/${activeOrganizationId}/policies`); revalidateTag('policies', 'max'); diff --git a/apps/app/src/actions/policies/create-version.ts b/apps/app/src/actions/policies/create-version.ts new file mode 100644 index 000000000..e1c17f0c1 --- /dev/null +++ b/apps/app/src/actions/policies/create-version.ts @@ -0,0 +1,170 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; +import { db } from '@db'; +import type { Prisma } from '@db'; +import { authActionClient } from '../safe-action'; +import { BUCKET_NAME, s3Client } from '@/app/s3'; +import { CopyObjectCommand } from '@aws-sdk/client-s3'; + +const VERSION_CREATE_RETRIES = 3; + +const createVersionSchema = z.object({ + policyId: z.string().min(1, 'Policy ID is required'), + changelog: z.string().optional(), + entityId: z.string(), +}); + +async function copyPolicyVersionPdf( + sourceKey: string, + destinationKey: string, +): Promise { + if (!s3Client || !BUCKET_NAME) { + return null; + } + try { + await s3Client.send( + new CopyObjectCommand({ + Bucket: BUCKET_NAME, + CopySource: `${BUCKET_NAME}/${sourceKey}`, + Key: destinationKey, + }), + ); + return destinationKey; + } catch (error) { + console.error('Error copying policy PDF:', error); + return null; + } +} + +export const createVersionAction = authActionClient + .inputSchema(createVersionSchema) + .metadata({ + name: 'create-policy-version', + track: { + event: 'create-policy-version', + description: 'Created new policy version draft', + channel: 'server', + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { policyId, changelog } = parsedInput; + const { activeOrganizationId, userId } = ctx.session; + + if (!activeOrganizationId) { + return { success: false, error: 'Not authorized' }; + } + + // Get member ID for publishedById + let memberId: string | null = null; + if (userId) { + const member = await db.member.findFirst({ + where: { userId, organizationId: activeOrganizationId, deactivated: false }, + select: { id: true }, + }); + memberId = member?.id ?? null; + } + + // Get policy with current version + const policy = await db.policy.findUnique({ + where: { id: policyId, organizationId: activeOrganizationId }, + include: { + currentVersion: true, + }, + }); + + if (!policy) { + return { success: false, error: 'Policy not found' }; + } + + // Source version is the current (published) version + const sourceVersion = policy.currentVersion; + const contentForVersion = sourceVersion + ? (sourceVersion.content as Prisma.InputJsonValue[]) + : (policy.content as Prisma.InputJsonValue[]); + const sourcePdfUrl = sourceVersion?.pdfUrl ?? policy.pdfUrl; + + if (!contentForVersion || contentForVersion.length === 0) { + return { success: false, error: 'No content to create version from' }; + } + + // Create version with retry logic for race conditions + // S3 copy is done AFTER the transaction to prevent orphaned files on retry + let createdVersion: { versionId: string; version: number } | null = null; + + for (let attempt = 1; attempt <= VERSION_CREATE_RETRIES; attempt++) { + try { + createdVersion = await db.$transaction(async (tx) => { + const latestVersion = await tx.policyVersion.findFirst({ + where: { policyId }, + orderBy: { version: 'desc' }, + select: { version: true }, + }); + const nextVersion = (latestVersion?.version ?? 0) + 1; + + // Create version WITHOUT PDF first (S3 copy happens after transaction) + const newVersion = await tx.policyVersion.create({ + data: { + policyId, + version: nextVersion, + content: contentForVersion, + pdfUrl: null, // Will be updated after S3 copy + publishedById: memberId, + changelog: changelog ?? null, + }, + }); + + return { + versionId: newVersion.id, + version: nextVersion, + }; + }); + + // Transaction succeeded, break out of retry loop + break; + } catch (error) { + // Check for unique constraint violation (P2002) + if ( + error instanceof Error && + 'code' in error && + (error as { code: string }).code === 'P2002' && + attempt < VERSION_CREATE_RETRIES + ) { + continue; + } + throw error; + } + } + + if (!createdVersion) { + return { success: false, error: 'Failed to create policy version after retries' }; + } + + // Now copy S3 file OUTSIDE the transaction (no orphaned files on retry) + if (sourcePdfUrl) { + try { + const newS3Key = `${activeOrganizationId}/policies/${policyId}/v${createdVersion.version}-${Date.now()}.pdf`; + const newPdfUrl = await copyPolicyVersionPdf(sourcePdfUrl, newS3Key); + + if (newPdfUrl) { + // Update the version with the PDF URL + await db.policyVersion.update({ + where: { id: createdVersion.versionId }, + data: { pdfUrl: newPdfUrl }, + }); + } + } catch (error) { + // Log but don't fail - version was created successfully, just without PDF + console.error('Error copying PDF for new version:', error); + } + } + + revalidatePath(`/${activeOrganizationId}/policies/${policyId}`); + revalidatePath(`/${activeOrganizationId}/policies`); + + return { + success: true, + data: createdVersion, + }; + }); diff --git a/apps/app/src/actions/policies/delete-policy.ts b/apps/app/src/actions/policies/delete-policy.ts index 405e2ba7c..a04a8bd5e 100644 --- a/apps/app/src/actions/policies/delete-policy.ts +++ b/apps/app/src/actions/policies/delete-policy.ts @@ -1,5 +1,7 @@ 'use server'; +import { BUCKET_NAME, s3Client } from '@/app/s3'; +import { DeleteObjectCommand } from '@aws-sdk/client-s3'; import { db } from '@db'; import { revalidatePath, revalidateTag } from 'next/cache'; import { z } from 'zod'; @@ -37,6 +39,11 @@ export const deletePolicyAction = authActionClient id, organizationId: activeOrganizationId, }, + include: { + versions: { + select: { pdfUrl: true }, + }, + }, }); if (!policy) { @@ -46,7 +53,36 @@ export const deletePolicyAction = authActionClient }; } - // Delete the policy + // Clean up S3 files before cascade delete + if (s3Client && BUCKET_NAME) { + const pdfUrlsToDelete: string[] = []; + + // Add policy-level PDF if exists + if (policy.pdfUrl) { + pdfUrlsToDelete.push(policy.pdfUrl); + } + + // Add all version PDFs + for (const version of policy.versions) { + if (version.pdfUrl) { + pdfUrlsToDelete.push(version.pdfUrl); + } + } + + // Delete all PDFs from S3 + await Promise.allSettled( + pdfUrlsToDelete.map((pdfUrl) => + s3Client.send( + new DeleteObjectCommand({ + Bucket: BUCKET_NAME, + Key: pdfUrl, + }), + ), + ), + ); + } + + // Delete the policy (versions are cascade deleted) await db.policy.delete({ where: { id }, }); @@ -54,6 +90,8 @@ export const deletePolicyAction = authActionClient // Revalidate paths to update UI revalidatePath(`/${activeOrganizationId}/policies`); revalidateTag('policies', 'max'); + + return { success: true }; } catch (error) { console.error(error); return { diff --git a/apps/app/src/actions/policies/delete-version.ts b/apps/app/src/actions/policies/delete-version.ts new file mode 100644 index 000000000..e7a606381 --- /dev/null +++ b/apps/app/src/actions/policies/delete-version.ts @@ -0,0 +1,104 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; +import { db } from '@db'; +import { authActionClient } from '../safe-action'; +import { BUCKET_NAME, s3Client } from '@/app/s3'; +import { DeleteObjectCommand } from '@aws-sdk/client-s3'; + +const deleteVersionSchema = z.object({ + versionId: z.string().min(1, 'Version ID is required'), + policyId: z.string().min(1, 'Policy ID is required'), +}); + +async function deletePolicyVersionPdf(s3Key: string): Promise { + if (!s3Client || !BUCKET_NAME) { + return; + } + try { + await s3Client.send( + new DeleteObjectCommand({ + Bucket: BUCKET_NAME, + Key: s3Key, + }), + ); + } catch (error) { + console.error('Error deleting policy PDF:', error); + } +} + +export const deleteVersionAction = authActionClient + .inputSchema(deleteVersionSchema) + .metadata({ + name: 'delete-policy-version', + track: { + event: 'delete-policy-version', + description: 'Delete a policy version', + channel: 'server', + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { versionId, policyId } = parsedInput; + const { activeOrganizationId } = ctx.session; + + if (!activeOrganizationId) { + return { success: false, error: 'Not authorized' }; + } + + // Verify policy exists and belongs to organization + const policy = await db.policy.findUnique({ + where: { id: policyId, organizationId: activeOrganizationId }, + select: { + id: true, + currentVersionId: true, + pendingVersionId: true, + }, + }); + + if (!policy) { + return { success: false, error: 'Policy not found' }; + } + + // Get version to delete + const version = await db.policyVersion.findUnique({ + where: { id: versionId }, + select: { + id: true, + policyId: true, + pdfUrl: true, + version: true, + }, + }); + + if (!version || version.policyId !== policyId) { + return { success: false, error: 'Version not found' }; + } + + // Cannot delete published version + if (version.id === policy.currentVersionId) { + return { success: false, error: 'Cannot delete the published version' }; + } + + // Cannot delete pending version + if (version.id === policy.pendingVersionId) { + return { success: false, error: 'Cannot delete a version pending approval' }; + } + + // Delete PDF from S3 if exists + if (version.pdfUrl) { + await deletePolicyVersionPdf(version.pdfUrl); + } + + // Delete version + await db.policyVersion.delete({ + where: { id: versionId }, + }); + + revalidatePath(`/${activeOrganizationId}/policies/${policyId}`); + + return { + success: true, + data: { deletedVersion: version.version }, + }; + }); diff --git a/apps/app/src/actions/policies/deny-requested-policy-changes.ts b/apps/app/src/actions/policies/deny-requested-policy-changes.ts index e5cc4450f..a5ac5a561 100644 --- a/apps/app/src/actions/policies/deny-requested-policy-changes.ts +++ b/apps/app/src/actions/policies/deny-requested-policy-changes.ts @@ -51,14 +51,19 @@ export const denyRequestedPolicyChangesAction = authActionClient } // Update policy status + // If there's a current published version, keep status as published + // Otherwise, set to draft + const newStatus = policy.currentVersionId ? PolicyStatus.published : PolicyStatus.draft; + await db.policy.update({ where: { id, organizationId: session.activeOrganizationId, }, data: { - status: PolicyStatus.draft, + status: newStatus, approverId: null, + pendingVersionId: null, // Clear the pending version }, }); diff --git a/apps/app/src/actions/policies/discard-draft-changes.ts b/apps/app/src/actions/policies/discard-draft-changes.ts new file mode 100644 index 000000000..d718ff227 --- /dev/null +++ b/apps/app/src/actions/policies/discard-draft-changes.ts @@ -0,0 +1,82 @@ +'use server'; + +import { db, type Prisma } from '@db'; +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; +import { authActionClient } from '../safe-action'; + +const discardDraftChangesSchema = z.object({ + policyId: z.string().min(1, 'Policy ID is required'), + entityId: z.string(), +}); + +export const discardDraftChangesAction = authActionClient + .inputSchema(discardDraftChangesSchema) + .metadata({ + name: 'discard-policy-draft-changes', + track: { + event: 'discard-policy-draft-changes', + description: 'Discarded policy draft changes', + channel: 'server', + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { policyId } = parsedInput; + const { activeOrganizationId } = ctx.session; + const { user } = ctx; + + if (!activeOrganizationId) { + return { + success: false, + error: 'Not authorized', + }; + } + + if (!user) { + return { + success: false, + error: 'Not authorized', + }; + } + + try { + // Get the policy with its current active version + const policy = await db.policy.findUnique({ + where: { id: policyId, organizationId: activeOrganizationId }, + include: { + currentVersion: true, + }, + }); + + if (!policy) { + return { + success: false, + error: 'Policy not found', + }; + } + + // Reset draft to the active version content, or to empty if no active version + const contentToRestore = (policy.currentVersion?.content ?? + policy.content ?? + []) as Prisma.InputJsonValue[]; + + await db.policy.update({ + where: { id: policyId }, + data: { + draftContent: contentToRestore, + }, + }); + + revalidatePath(`/${activeOrganizationId}/policies/${policyId}`); + + return { + success: true, + }; + } catch (error) { + console.error('Error discarding policy draft changes:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to discard draft changes', + }; + } + }); diff --git a/apps/app/src/actions/policies/get-policy-versions.ts b/apps/app/src/actions/policies/get-policy-versions.ts new file mode 100644 index 000000000..63d4a5706 --- /dev/null +++ b/apps/app/src/actions/policies/get-policy-versions.ts @@ -0,0 +1,61 @@ +'use server'; + +import { z } from 'zod'; +import { db } from '@db'; +import { authActionClient } from '../safe-action'; + +const getPolicyVersionsSchema = z.object({ + policyId: z.string().min(1, 'Policy ID is required'), +}); + +export const getPolicyVersionsAction = authActionClient + .inputSchema(getPolicyVersionsSchema) + .metadata({ + name: 'get-policy-versions', + }) + .action(async ({ parsedInput, ctx }) => { + const { policyId } = parsedInput; + const { activeOrganizationId } = ctx.session; + + if (!activeOrganizationId) { + return { success: false, error: 'Not authorized' }; + } + + // Verify policy exists and belongs to organization + const policy = await db.policy.findFirst({ + where: { id: policyId, organizationId: activeOrganizationId }, + select: { id: true, currentVersionId: true, pendingVersionId: true }, + }); + + if (!policy) { + return { success: false, error: 'Policy not found' }; + } + + // Get all versions + const versions = await db.policyVersion.findMany({ + where: { policyId }, + orderBy: { version: 'desc' }, + include: { + publishedBy: { + include: { + user: { + select: { + id: true, + name: true, + image: true, + }, + }, + }, + }, + }, + }); + + return { + success: true, + data: { + versions, + currentVersionId: policy.currentVersionId, + pendingVersionId: policy.pendingVersionId, + }, + }; + }); diff --git a/apps/app/src/actions/policies/migrate-policies-to-versioning.ts b/apps/app/src/actions/policies/migrate-policies-to-versioning.ts new file mode 100644 index 000000000..9e3714abd --- /dev/null +++ b/apps/app/src/actions/policies/migrate-policies-to-versioning.ts @@ -0,0 +1,179 @@ +'use server'; + +import { db, PolicyStatus, type Prisma } from '@db'; +import { authActionClient } from '../safe-action'; + +/** + * Migrates existing policies that don't have versions to have version 1. + * This is a one-time migration action that should be run for organizations + * that were created before the versioning feature was introduced. + * + * This action: + * 1. Finds all policies in the organization without a currentVersionId + * 2. Creates version 1 for each policy using its current content + * 3. Sets that version as the current (published) version if policy status is published + */ +export const migratePoliciesAction = authActionClient + .metadata({ + name: 'migrate-policies-to-versioning', + track: { + event: 'migrate-policies-to-versioning', + description: 'Migrate existing policies to versioning system', + channel: 'server', + }, + }) + .action(async ({ ctx }) => { + const { activeOrganizationId } = ctx.session; + const { user } = ctx; + + if (!activeOrganizationId) { + return { + success: false, + error: 'Not authorized', + }; + } + + if (!user) { + return { + success: false, + error: 'Not authorized', + }; + } + + // Get the member ID for associating with versions + const member = await db.member.findFirst({ + where: { + userId: user.id, + organizationId: activeOrganizationId, + deactivated: false, + }, + select: { id: true }, + }); + + try { + // Find all policies without a currentVersionId + const policiesWithoutVersions = await db.policy.findMany({ + where: { + organizationId: activeOrganizationId, + currentVersionId: null, + }, + select: { + id: true, + content: true, + status: true, + pdfUrl: true, + }, + }); + + if (policiesWithoutVersions.length === 0) { + return { + success: true, + message: 'No policies need migration', + migratedCount: 0, + }; + } + + // Migrate each policy in a transaction + const migratedCount = await db.$transaction(async (tx) => { + let count = 0; + + for (const policy of policiesWithoutVersions) { + // Create version 1 + const version = await tx.policyVersion.create({ + data: { + policyId: policy.id, + version: 1, + content: (policy.content as Prisma.InputJsonValue[]) || [], + pdfUrl: policy.pdfUrl, // Copy over any existing PDF + publishedById: member?.id || null, + changelog: 'Migrated from legacy policy', + }, + }); + + // Update policy to set currentVersionId + await tx.policy.update({ + where: { id: policy.id }, + data: { + currentVersionId: version.id, + // Ensure status is set properly + status: + policy.status === PolicyStatus.published + ? PolicyStatus.published + : PolicyStatus.draft, + }, + }); + + count++; + } + + return count; + }); + + return { + success: true, + message: `Successfully migrated ${migratedCount} policies to versioning`, + migratedCount, + }; + } catch (error) { + console.error('Error migrating policies:', error); + return { + success: false, + error: 'Failed to migrate policies', + }; + } + }); + +/** + * Utility function to migrate a single policy to versioning. + * Can be called from other server actions or components when needed. + */ +export async function ensurePolicyHasVersion( + policyId: string, + organizationId: string, + memberId?: string, +): Promise { + const policy = await db.policy.findUnique({ + where: { id: policyId, organizationId }, + select: { + id: true, + content: true, + status: true, + pdfUrl: true, + currentVersionId: true, + }, + }); + + if (!policy) { + return null; + } + + // Already has a version + if (policy.currentVersionId) { + return policy.currentVersionId; + } + + // Create version 1 + const version = await db.$transaction(async (tx) => { + const newVersion = await tx.policyVersion.create({ + data: { + policyId: policy.id, + version: 1, + content: (policy.content as Prisma.InputJsonValue[]) || [], + pdfUrl: policy.pdfUrl, + publishedById: memberId || null, + changelog: 'Migrated from legacy policy', + }, + }); + + await tx.policy.update({ + where: { id: policy.id }, + data: { + currentVersionId: newVersion.id, + }, + }); + + return newVersion; + }); + + return version.id; +} diff --git a/apps/app/src/actions/policies/publish-version.ts b/apps/app/src/actions/policies/publish-version.ts new file mode 100644 index 000000000..26500bad3 --- /dev/null +++ b/apps/app/src/actions/policies/publish-version.ts @@ -0,0 +1,131 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; +import { db } from '@db'; +import type { Prisma } from '@db'; +import { authActionClient } from '../safe-action'; + +const VERSION_CREATE_RETRIES = 3; + +const publishVersionSchema = z.object({ + policyId: z.string().min(1, 'Policy ID is required'), + changelog: z.string().optional(), + setAsActive: z.boolean().default(true), + entityId: z.string(), +}); + +export const publishVersionAction = authActionClient + .inputSchema(publishVersionSchema) + .metadata({ + name: 'publish-policy-version', + track: { + event: 'publish-policy-version', + description: 'Published new policy version', + channel: 'server', + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { policyId, changelog, setAsActive } = parsedInput; + const { activeOrganizationId, userId } = ctx.session; + + if (!activeOrganizationId) { + return { success: false, error: 'Not authorized' }; + } + + // Get member ID for publishedById + let memberId: string | null = null; + if (userId) { + const member = await db.member.findFirst({ + where: { userId, organizationId: activeOrganizationId, deactivated: false }, + select: { id: true }, + }); + memberId = member?.id ?? null; + } + + // Get policy + const policy = await db.policy.findUnique({ + where: { id: policyId, organizationId: activeOrganizationId }, + }); + + if (!policy) { + return { success: false, error: 'Policy not found' }; + } + + const contentToPublish = ( + policy.draftContent && (policy.draftContent as unknown[]).length > 0 + ? policy.draftContent + : policy.content + ) as Prisma.InputJsonValue[]; + + if (!contentToPublish || contentToPublish.length === 0) { + return { success: false, error: 'No content to publish' }; + } + + // Create version with retry logic for race conditions + for (let attempt = 1; attempt <= VERSION_CREATE_RETRIES; attempt++) { + try { + const result = await db.$transaction(async (tx) => { + const latestVersion = await tx.policyVersion.findFirst({ + where: { policyId }, + orderBy: { version: 'desc' }, + select: { version: true }, + }); + const nextVersion = (latestVersion?.version ?? 0) + 1; + + const newVersion = await tx.policyVersion.create({ + data: { + policyId, + version: nextVersion, + content: contentToPublish, + pdfUrl: policy.pdfUrl, + publishedById: memberId, + changelog: changelog ?? null, + }, + }); + + await tx.policy.update({ + where: { id: policyId }, + data: { + content: contentToPublish, + draftContent: contentToPublish, + lastPublishedAt: new Date(), + status: 'published', + // Clear any pending approval since we're publishing directly + pendingVersionId: null, + approverId: null, + // Clear signatures - employees must re-acknowledge new content + signedBy: [], + ...(setAsActive !== false && { currentVersionId: newVersion.id }), + }, + }); + + return { + versionId: newVersion.id, + version: nextVersion, + }; + }); + + revalidatePath(`/${activeOrganizationId}/policies/${policyId}`); + revalidatePath(`/${activeOrganizationId}/policies`); + + return { + success: true, + data: result, + }; + } catch (error) { + // Check for unique constraint violation (P2002) + if ( + error instanceof Error && + 'code' in error && + (error as { code: string }).code === 'P2002' && + attempt < VERSION_CREATE_RETRIES + ) { + continue; + } + throw error; + } + } + + return { success: false, error: 'Failed to publish policy version after retries' }; + }); diff --git a/apps/app/src/actions/policies/restore-version-to-draft.ts b/apps/app/src/actions/policies/restore-version-to-draft.ts new file mode 100644 index 000000000..ad5858e08 --- /dev/null +++ b/apps/app/src/actions/policies/restore-version-to-draft.ts @@ -0,0 +1,92 @@ +'use server'; + +import { db, type Prisma } from '@db'; +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; +import { authActionClient } from '../safe-action'; + +const restoreVersionToDraftSchema = z.object({ + policyId: z.string().min(1, 'Policy ID is required'), + versionId: z.string().min(1, 'Version ID is required'), + entityId: z.string(), +}); + +export const restoreVersionToDraftAction = authActionClient + .inputSchema(restoreVersionToDraftSchema) + .metadata({ + name: 'restore-policy-version-to-draft', + track: { + event: 'restore-policy-version-to-draft', + description: 'Restored policy version to draft', + channel: 'server', + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { policyId, versionId } = parsedInput; + const { activeOrganizationId } = ctx.session; + const { user } = ctx; + + if (!activeOrganizationId) { + return { + success: false, + error: 'Not authorized', + }; + } + + if (!user) { + return { + success: false, + error: 'Not authorized', + }; + } + + try { + // Verify the policy belongs to the organization + const policy = await db.policy.findUnique({ + where: { id: policyId, organizationId: activeOrganizationId }, + }); + + if (!policy) { + return { + success: false, + error: 'Policy not found', + }; + } + + // Get the version to restore + const version = await db.policyVersion.findUnique({ + where: { id: versionId }, + }); + + if (!version || version.policyId !== policyId) { + return { + success: false, + error: 'Version not found', + }; + } + + // Copy the version content to draftContent + await db.policy.update({ + where: { id: policyId }, + data: { + draftContent: version.content as Prisma.InputJsonValue[], + }, + }); + + revalidatePath(`/${activeOrganizationId}/policies/${policyId}`); + + return { + success: true, + data: { + versionId: version.id, + version: version.version, + }, + }; + } catch (error) { + console.error('Error restoring policy version to draft:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to restore version to draft', + }; + } + }); diff --git a/apps/app/src/actions/policies/set-active-version.ts b/apps/app/src/actions/policies/set-active-version.ts new file mode 100644 index 000000000..cd5bede62 --- /dev/null +++ b/apps/app/src/actions/policies/set-active-version.ts @@ -0,0 +1,82 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; +import { db } from '@db'; +import type { Prisma } from '@db'; +import { authActionClient } from '../safe-action'; + +const setActiveVersionSchema = z.object({ + policyId: z.string().min(1, 'Policy ID is required'), + versionId: z.string().min(1, 'Version ID is required'), + entityId: z.string(), +}); + +export const setActiveVersionAction = authActionClient + .inputSchema(setActiveVersionSchema) + .metadata({ + name: 'set-active-policy-version', + track: { + event: 'set-active-policy-version', + description: 'Set policy version as active', + channel: 'server', + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { policyId, versionId } = parsedInput; + const { activeOrganizationId } = ctx.session; + + if (!activeOrganizationId) { + return { success: false, error: 'Not authorized' }; + } + + // Verify policy exists and belongs to organization + const policy = await db.policy.findUnique({ + where: { id: policyId, organizationId: activeOrganizationId }, + }); + + if (!policy) { + return { success: false, error: 'Policy not found' }; + } + + // Prevent activating a different version when another is pending approval + if (policy.pendingVersionId && policy.pendingVersionId !== versionId) { + return { success: false, error: 'Another version is already pending approval' }; + } + + // Get version to activate + const version = await db.policyVersion.findUnique({ + where: { id: versionId }, + }); + + if (!version || version.policyId !== policyId) { + return { success: false, error: 'Version not found' }; + } + + // Update policy to set this version as active + // Clear pending approval state since we're directly activating a version + await db.policy.update({ + where: { id: policyId }, + data: { + currentVersionId: versionId, + content: version.content as Prisma.InputJsonValue[], + draftContent: version.content as Prisma.InputJsonValue[], // Sync draft to prevent "unpublished changes" UI bug + status: 'published', + pendingVersionId: null, + approverId: null, + // Clear signatures - employees must re-acknowledge new content + signedBy: [], + }, + }); + + revalidatePath(`/${activeOrganizationId}/policies/${policyId}`); + revalidatePath(`/${activeOrganizationId}/policies`); + + return { + success: true, + data: { + versionId: version.id, + version: version.version, + }, + }; + }); diff --git a/apps/app/src/actions/policies/submit-version-for-approval.ts b/apps/app/src/actions/policies/submit-version-for-approval.ts new file mode 100644 index 000000000..a0718e40d --- /dev/null +++ b/apps/app/src/actions/policies/submit-version-for-approval.ts @@ -0,0 +1,95 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; +import { db, PolicyStatus } from '@db'; +import { authActionClient } from '../safe-action'; + +const submitVersionForApprovalSchema = z.object({ + policyId: z.string().min(1, 'Policy ID is required'), + versionId: z.string().min(1, 'Version ID is required'), + approverId: z.string().min(1, 'Approver is required'), + entityId: z.string(), +}); + +export const submitVersionForApprovalAction = authActionClient + .inputSchema(submitVersionForApprovalSchema) + .metadata({ + name: 'submit-version-for-approval', + track: { + event: 'submit-version-for-approval', + description: 'Submit policy version for approval', + channel: 'server', + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { policyId, versionId, approverId } = parsedInput; + const { activeOrganizationId } = ctx.session; + + if (!activeOrganizationId) { + return { success: false, error: 'Not authorized' }; + } + + // Verify policy exists and belongs to organization + const policy = await db.policy.findUnique({ + where: { id: policyId, organizationId: activeOrganizationId }, + }); + + if (!policy) { + return { success: false, error: 'Policy not found' }; + } + + // Check if another version is already pending + if (policy.pendingVersionId && policy.pendingVersionId !== versionId) { + return { success: false, error: 'Another version is already pending approval' }; + } + + // Get version + const version = await db.policyVersion.findUnique({ + where: { id: versionId }, + }); + + if (!version || version.policyId !== policyId) { + return { success: false, error: 'Version not found' }; + } + + // Cannot submit the already-active version for approval + if (versionId === policy.currentVersionId) { + return { success: false, error: 'Cannot submit the currently published version for approval' }; + } + + // Verify approver exists and belongs to organization + const approver = await db.member.findUnique({ + where: { id: approverId }, + }); + + if (!approver || approver.organizationId !== activeOrganizationId) { + return { success: false, error: 'Approver not found' }; + } + + // Cannot assign a deactivated member as approver - they can't log in to approve + if (approver.deactivated) { + return { success: false, error: 'Cannot assign a deactivated member as approver' }; + } + + // Update policy to set pending version and status + await db.policy.update({ + where: { id: policyId }, + data: { + pendingVersionId: versionId, + status: PolicyStatus.needs_review, + approverId, + }, + }); + + revalidatePath(`/${activeOrganizationId}/policies/${policyId}`); + revalidatePath(`/${activeOrganizationId}/policies`); + + return { + success: true, + data: { + versionId: version.id, + version: version.version, + }, + }; + }); diff --git a/apps/app/src/actions/policies/update-draft.ts b/apps/app/src/actions/policies/update-draft.ts new file mode 100644 index 000000000..5031f0c06 --- /dev/null +++ b/apps/app/src/actions/policies/update-draft.ts @@ -0,0 +1,131 @@ +'use server'; + +import { db } from '@db'; +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; +import { authActionClient } from '../safe-action'; + +interface ContentNode { + type: string; + content?: ContentNode[]; + text?: string; + attrs?: Record; + marks?: Array<{ type: string; attrs?: Record }>; + [key: string]: unknown; +} + +// Simplified content processor that creates a new plain object +function processContent(content: ContentNode | ContentNode[]): ContentNode | ContentNode[] { + if (!content) return content; + + // Handle arrays + if (Array.isArray(content)) { + return content.map((node) => processContent(node) as ContentNode); + } + + // Create a new plain object with only the necessary properties + const processed: ContentNode = { + type: content.type, + }; + + if (content.text !== undefined) { + processed.text = content.text; + } + + if (content.attrs) { + processed.attrs = { ...content.attrs }; + } + + if (content.marks) { + processed.marks = content.marks.map((mark) => ({ + type: mark.type, + ...(mark.attrs && { attrs: { ...mark.attrs } }), + })); + } + + if (content.content) { + processed.content = processContent(content.content) as ContentNode[]; + } + + return processed; +} + +const updateDraftSchema = z.object({ + policyId: z.string().min(1, 'Policy ID is required'), + content: z.any(), + entityId: z.string(), +}); + +export const updateDraftAction = authActionClient + .inputSchema(updateDraftSchema) + .metadata({ + name: 'update-policy-draft', + track: { + event: 'update-policy-draft', + description: 'Updated policy draft', + channel: 'server', + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { policyId, content } = parsedInput; + const { activeOrganizationId } = ctx.session; + const { user } = ctx; + + if (!activeOrganizationId) { + return { + success: false, + error: 'Not authorized', + }; + } + + if (!user) { + return { + success: false, + error: 'Not authorized', + }; + } + + try { + const policy = await db.policy.findUnique({ + where: { id: policyId, organizationId: activeOrganizationId }, + }); + + if (!policy) { + return { + success: false, + error: 'Policy not found', + }; + } + + // Create a new plain object from the content + const processedContent = JSON.parse(JSON.stringify(processContent(content as ContentNode | ContentNode[]))); + + // Handle both array format and TipTap wrapper object format + // If processedContent is an array, use it directly + // If it's a wrapper object with .content, extract the content array + const draftContentToSave = Array.isArray(processedContent) + ? processedContent + : processedContent.content ?? [processedContent]; + + await db.policy.update({ + where: { id: policyId }, + data: { + draftContent: draftContentToSave, + // Note: Do NOT clear signedBy here - signatures are for published content, + // not drafts. Signatures are only cleared when content is actually published. + }, + }); + + revalidatePath(`/${activeOrganizationId}/policies/${policyId}`); + + return { + success: true, + }; + } catch (error) { + console.error('Error updating policy draft:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to update policy draft', + }; + } + }); diff --git a/apps/app/src/actions/policies/update-version-content.ts b/apps/app/src/actions/policies/update-version-content.ts new file mode 100644 index 000000000..1301ec941 --- /dev/null +++ b/apps/app/src/actions/policies/update-version-content.ts @@ -0,0 +1,129 @@ +'use server'; + +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; +import { db } from '@db'; +import type { Prisma } from '@db'; +import { authActionClient } from '../safe-action'; + +interface ContentNode { + type: string; + content?: ContentNode[]; + text?: string; + attrs?: Record; + marks?: Array<{ type: string; attrs?: Record }>; + [key: string]: unknown; +} + +// Process content to ensure it's a plain serializable object +function processContent(content: ContentNode | ContentNode[]): ContentNode | ContentNode[] { + if (!content) return content; + + if (Array.isArray(content)) { + return content.map((node) => processContent(node) as ContentNode); + } + + const processed: ContentNode = { type: content.type }; + + if (content.text !== undefined) { + processed.text = content.text; + } + + if (content.attrs) { + processed.attrs = { ...content.attrs }; + } + + if (content.marks) { + processed.marks = content.marks.map((mark) => ({ + type: mark.type, + ...(mark.attrs && { attrs: { ...mark.attrs } }), + })); + } + + if (content.content) { + processed.content = processContent(content.content) as ContentNode[]; + } + + return processed; +} + +const updateVersionContentSchema = z.object({ + policyId: z.string().min(1, 'Policy ID is required'), + versionId: z.string().min(1, 'Version ID is required'), + content: z.any(), // TipTap content can be complex + entityId: z.string(), // Required for audit tracking +}); + +export const updateVersionContentAction = authActionClient + .inputSchema(updateVersionContentSchema) + .metadata({ + name: 'update-version-content', + track: { + event: 'update-version-content', + description: 'Update policy version content', + channel: 'server', + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { policyId, versionId, content } = parsedInput; + const { activeOrganizationId } = ctx.session; + + if (!activeOrganizationId) { + return { success: false, error: 'Not authorized' }; + } + + // Verify version exists and belongs to organization + const version = await db.policyVersion.findUnique({ + where: { id: versionId }, + include: { + policy: { + select: { + id: true, + organizationId: true, + currentVersionId: true, + pendingVersionId: true, + }, + }, + }, + }); + + if (!version || version.policy.organizationId !== activeOrganizationId) { + return { success: false, error: 'Version not found' }; + } + + if (version.policy.id !== policyId) { + return { success: false, error: 'Version does not belong to this policy' }; + } + + // Cannot edit published version + if (version.id === version.policy.currentVersionId) { + return { + success: false, + error: 'Cannot edit the published version. Create a new version to make changes.', + }; + } + + // Cannot edit pending version + if (version.id === version.policy.pendingVersionId) { + return { + success: false, + error: 'Cannot edit a version that is pending approval.', + }; + } + + const processedContent = JSON.parse( + JSON.stringify(processContent(content as ContentNode[])), + ) as Prisma.InputJsonValue[]; + + await db.policyVersion.update({ + where: { id: versionId }, + data: { content: processedContent }, + }); + + revalidatePath(`/${activeOrganizationId}/policies/${policyId}`); + + return { + success: true, + data: { versionId }, + }; + }); diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/components/FindingsOverview.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/components/FindingsOverview.tsx index d35037136..da7363f98 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/components/FindingsOverview.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/components/FindingsOverview.tsx @@ -18,7 +18,7 @@ const STATUS_COLORS: Record = { const STATUS_LABELS: Record = { open: 'Open', - ready_for_review: 'Review', + ready_for_review: 'Auditor Review', needs_revision: 'Revision', closed: 'Closed', }; @@ -215,7 +215,7 @@ export function FindingsOverview({ 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 a4643cd52..3bc4ab5df 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 @@ -269,8 +269,14 @@ export function MemberRow({ /> + {'Are you sure you want to remove all devices for this user '} {memberName}?{' '} + {'This will disconnect all devices from the organization.'} + + )} onOpenChange={setIsRemoveDeviceAlertOpen} - memberName={memberName} onRemove={handleRemoveDeviceClick} isRemoving={isRemovingDevice} /> diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/RemoveDeviceAlert.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/RemoveDeviceAlert.tsx index f02f22524..df9850607 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/RemoveDeviceAlert.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/RemoveDeviceAlert.tsx @@ -10,19 +10,22 @@ import { AlertDialogTitle, } from '@comp/ui/alert-dialog'; import { Button } from '@comp/ui/button'; +import { ReactNode } from 'react'; interface RemoveDeviceAlertProps { open: boolean; + title: string; + description: ReactNode; onOpenChange: (open: boolean) => void; - memberName: string; onRemove: () => void; isRemoving: boolean; } export function RemoveDeviceAlert({ open, + title, + description, onOpenChange, - memberName, onRemove, isRemoving, }: RemoveDeviceAlertProps) { @@ -30,10 +33,9 @@ export function RemoveDeviceAlert({ - {'Remove Device'} + {title} - {'Are you sure you want to remove the device for this user '} {memberName}?{' '} - {'This will disconnect the device from the organization.'} + {description} 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 533644ea3..c04899ab9 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 @@ -18,7 +18,15 @@ export interface TeamMembersData { pendingInvitations: Invitation[]; } -export async function TeamMembers() { +export interface TeamMembersProps { + canManageMembers: boolean; + canInviteUsers: boolean; + isAuditor: boolean; + isCurrentUserOwner: boolean; +} + +export async function TeamMembers(props: TeamMembersProps) { + const { canManageMembers, canInviteUsers, isAuditor, isCurrentUserOwner } = props; const session = await auth.api.getSession({ headers: await headers(), }); @@ -28,20 +36,6 @@ export async function TeamMembers() { return null; } - const currentUserMember = await db.member.findFirst({ - where: { - organizationId: organizationId, - userId: session?.user.id, - }, - }); - - // Parse roles from comma-separated string and check if user has admin or owner role - const currentUserRoles = currentUserMember?.role?.split(',').map((r) => r.trim()) ?? []; - const canManageMembers = currentUserRoles.some((role) => ['owner', 'admin'].includes(role)); - const isAuditor = currentUserRoles.includes('auditor'); - const canInviteUsers = canManageMembers || isAuditor; - const isCurrentUserOwner = currentUserRoles.includes('owner'); - let members: MemberWithUser[] = []; let pendingInvitations: Invitation[] = []; diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceDropdownMenu.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceDropdownMenu.tsx new file mode 100644 index 000000000..748a0c573 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceDropdownMenu.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { Button } from '@comp/ui'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@comp/ui/dropdown-menu'; +import { Laptop, MoreHorizontal } from 'lucide-react'; +import { Host } from '../types'; +import { RemoveDeviceAlert } from '../../all/components/RemoveDeviceAlert'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import { usePeopleActions } from '@/hooks/use-people-api'; +import { useRouter } from 'next/navigation'; + +interface DeviceDropdownMenuProps { + host: Host; + isCurrentUserOwner: boolean; +} + +export const DeviceDropdownMenu = ({ host, isCurrentUserOwner }: DeviceDropdownMenuProps) => { + const router = useRouter(); + const [isRemoveDeviceAlertOpen, setIsRemoveDeviceAlertOpen] = useState(false); + const [isRemovingDevice, setIsRemovingDevice] = useState(false); + + const { removeHostFromFleet } = usePeopleActions(); + + if (!isCurrentUserOwner || !host.member_id) { + return null; + } + + const memberId = host.member_id; + + const handleRemoveDeviceClick = async () => { + try { + setIsRemovingDevice(true); + await removeHostFromFleet(memberId, host.id); + setIsRemoveDeviceAlertOpen(false); + toast.success('Device removed successfully'); + router.refresh(); // Revalidate data to update UI + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to remove device'); + } finally { + setIsRemovingDevice(false); + } + }; + + return ( +
e.stopPropagation()}> + + + + + + setIsRemoveDeviceAlertOpen(true)}> + + {'Remove Device'} + + + + + {'Are you sure you want to remove this device '} {host.computer_name}?{' '} + {'This will disconnect the device from the user.'} + + )} + onOpenChange={setIsRemoveDeviceAlertOpen} + onRemove={handleRemoveDeviceClick} + isRemoving={isRemovingDevice} + /> +
+ ); +}; \ No newline at end of file diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/EmployeeDevicesColumns.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/EmployeeDevicesColumns.tsx index 3388c5a4c..11ee1b9b0 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/EmployeeDevicesColumns.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/EmployeeDevicesColumns.tsx @@ -6,6 +6,7 @@ import { CheckCircle2, XCircle } from 'lucide-react'; import Link from 'next/link'; import { useParams } from 'next/navigation'; import type { FleetPolicy, Host } from '../types'; +import { DeviceDropdownMenu } from './DeviceDropdownMenu'; function UserNameCell({ userName, memberId }: { userName: string | null | undefined; memberId: string | undefined }) { const params = useParams(); @@ -26,7 +27,7 @@ function UserNameCell({ userName, memberId }: { userName: string | null | undefi ); } -export function getEmployeeDevicesColumns(): ColumnDef[] { +export function getEmployeeDevicesColumns(isCurrentUserOwner: boolean): ColumnDef[] { return [ { id: 'computer_name', @@ -72,5 +73,14 @@ export function getEmployeeDevicesColumns(): ColumnDef[] { ); }, }, + { + id: 'actions', + header: ({ column }) => , + enableColumnFilter: false, + enableSorting: false, + cell: ({ row }) => ( + + ), + } ]; } diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/EmployeeDevicesList.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/EmployeeDevicesList.tsx index ad806fe08..6d70b7258 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/EmployeeDevicesList.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/EmployeeDevicesList.tsx @@ -8,9 +8,14 @@ import type { Host } from '../types/index'; import { getEmployeeDevicesColumns } from './EmployeeDevicesColumns'; import { HostDetails } from './HostDetails'; -export const EmployeeDevicesList = ({ devices }: { devices: Host[] }) => { +export interface EmployeeDevicesListProps { + devices: Host[]; + isCurrentUserOwner: boolean; +} + +export const EmployeeDevicesList = ({ devices, isCurrentUserOwner }: EmployeeDevicesListProps) => { const [selectedRow, setSelectedRow] = useState(null); - const columns = useMemo(() => getEmployeeDevicesColumns(), []); + const columns = useMemo(() => getEmployeeDevicesColumns(isCurrentUserOwner), [isCurrentUserOwner]); const { table } = useDataTable({ data: devices, diff --git a/apps/app/src/app/(app)/[orgId]/people/page.tsx b/apps/app/src/app/(app)/[orgId]/people/page.tsx index b15f9784e..105ce94d6 100644 --- a/apps/app/src/app/(app)/[orgId]/people/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/page.tsx @@ -22,6 +22,18 @@ export default async function PeoplePage({ params }: { params: Promise<{ orgId: return redirect('/'); } + const currentUserMember = await db.member.findFirst({ + where: { + organizationId: orgId, + userId: session.user.id, + }, + }); + const currentUserRoles = currentUserMember?.role?.split(',').map((r) => r.trim()) ?? []; + const canManageMembers = currentUserRoles.some((role) => ['owner', 'admin'].includes(role)); + const isAuditor = currentUserRoles.includes('auditor'); + 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({ where: { @@ -49,12 +61,19 @@ export default async function PeoplePage({ params }: { params: Promise<{ orgId: return ( } + peopleContent={ + + } employeeTasksContent={showEmployeeTasks ? : null} devicesContent={ <> - + } showEmployeeTasks={showEmployeeTasks} 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 c23562160..001bdb932 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 @@ -5,11 +5,11 @@ import { BUCKET_NAME, s3Client } from '@/app/s3'; import { DeleteObjectCommand } from '@aws-sdk/client-s3'; import { db, PolicyDisplayFormat } from '@db'; import { revalidatePath } from 'next/cache'; -import { headers } from 'next/headers'; import { z } from 'zod'; const deletePolicyPdfSchema = z.object({ policyId: z.string(), + versionId: z.string().optional(), // If provided, delete from this version }); export const deletePolicyPdfAction = authActionClient @@ -22,7 +22,7 @@ export const deletePolicyPdfAction = authActionClient }, }) .action(async ({ parsedInput, ctx }) => { - const { policyId } = parsedInput; + const { policyId, versionId } = parsedInput; const { session } = ctx; const organizationId = session.activeOrganizationId; @@ -31,26 +31,61 @@ export const deletePolicyPdfAction = authActionClient } try { - // Get the policy to find the pdfUrl + // Verify policy belongs to organization const policy = await db.policy.findUnique({ where: { id: policyId, organizationId }, - select: { pdfUrl: true }, + select: { + id: true, + pdfUrl: true, + currentVersionId: true, + pendingVersionId: true, + }, }); if (!policy) { return { success: false, error: 'Policy not found' }; } - const oldPdfUrl = policy.pdfUrl; + let oldPdfUrl: string | null = null; - // Update policy first to remove pdfUrl and switch back to EDITOR format - await db.policy.update({ - where: { id: policyId, organizationId }, - data: { - pdfUrl: null, - displayFormat: PolicyDisplayFormat.EDITOR, - }, - }); + if (versionId) { + // Delete PDF from specific version + const version = await db.policyVersion.findUnique({ + where: { id: versionId }, + select: { id: true, policyId: true, pdfUrl: true }, + }); + + if (!version || version.policyId !== policyId) { + return { success: false, error: 'Version not found' }; + } + + // Don't allow deleting PDF from published or pending versions + if (version.id === policy.currentVersionId) { + return { success: false, error: 'Cannot delete PDF from the published version' }; + } + if (version.id === policy.pendingVersionId) { + return { success: false, error: 'Cannot delete PDF from a version pending approval' }; + } + + oldPdfUrl = version.pdfUrl; + + // Update version to remove pdfUrl + await db.policyVersion.update({ + where: { id: versionId }, + data: { pdfUrl: null }, + }); + } else { + // Legacy: delete from policy level + oldPdfUrl = policy.pdfUrl; + + await db.policy.update({ + where: { id: policyId, organizationId }, + data: { + pdfUrl: null, + displayFormat: PolicyDisplayFormat.EDITOR, + }, + }); + } // Delete from S3 after database is updated if (oldPdfUrl && s3Client && BUCKET_NAME) { @@ -61,15 +96,11 @@ export const deletePolicyPdfAction = authActionClient }); await s3Client.send(deleteCommand); } catch (error) { - // Log error but we've already updated the database successfully console.error('Error deleting PDF from S3 (orphaned file):', error); } } - const headersList = await headers(); - let path = headersList.get('x-pathname') || headersList.get('referer') || ''; - path = path.replace(/\/[a-z]{2}\//, '/'); - revalidatePath(path); + revalidatePath(`/${organizationId}/policies/${policyId}`); return { success: true }; } catch (error) { diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/actions/get-policy-pdf-url.ts b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/actions/get-policy-pdf-url.ts index 953472548..b2bef8b27 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/actions/get-policy-pdf-url.ts +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/actions/get-policy-pdf-url.ts @@ -8,7 +8,10 @@ import { db } from '@db'; import { z } from 'zod'; export const getPolicyPdfUrlAction = authActionClient - .inputSchema(z.object({ policyId: z.string() })) + .inputSchema(z.object({ + policyId: z.string(), + versionId: z.string().optional(), // If provided, get URL for this version's PDF + })) .metadata({ name: 'get-policy-pdf-url', track: { @@ -17,7 +20,7 @@ export const getPolicyPdfUrlAction = authActionClient }, }) .action(async ({ parsedInput, ctx }) => { - const { policyId } = parsedInput; + const { policyId, versionId } = parsedInput; const { session } = ctx; const organizationId = session.activeOrganizationId; @@ -30,19 +33,54 @@ export const getPolicyPdfUrlAction = authActionClient } try { - const policy = await db.policy.findUnique({ - where: { id: policyId, organizationId }, - select: { pdfUrl: true }, - }); + let pdfUrl: string | null = null; + + if (versionId) { + // Get PDF URL from specific version + // IMPORTANT: Include organizationId check to prevent cross-org access + const version = await db.policyVersion.findUnique({ + where: { id: versionId }, + select: { + pdfUrl: true, + policyId: true, + policy: { + select: { organizationId: true }, + }, + }, + }); + + if ( + !version || + version.policyId !== policyId || + version.policy.organizationId !== organizationId + ) { + return { success: false, error: 'Version not found' }; + } + + pdfUrl = version.pdfUrl; + } else { + // Legacy: get from policy level + const policy = await db.policy.findUnique({ + where: { id: policyId, organizationId }, + select: { + pdfUrl: true, + currentVersion: { + select: { pdfUrl: true }, + }, + }, + }); + + pdfUrl = policy?.currentVersion?.pdfUrl ?? policy?.pdfUrl ?? null; + } - if (!policy?.pdfUrl) { - return { success: false, error: 'No PDF found for this policy.' }; + if (!pdfUrl) { + return { success: false, error: 'No PDF found.' }; } // Generate a temporary, secure URL for the client to render the PDF from the private bucket. const command = new GetObjectCommand({ Bucket: BUCKET_NAME, - Key: policy.pdfUrl, + Key: pdfUrl, ResponseContentDisposition: 'inline', ResponseContentType: 'application/pdf', }); diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/actions/regenerate-policy.ts b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/actions/regenerate-policy.ts index 3e5b70f4e..6722f098b 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/actions/regenerate-policy.ts +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/actions/regenerate-policy.ts @@ -3,7 +3,7 @@ import { authActionClient } from '@/actions/safe-action'; import { updatePolicy } from '@/trigger/tasks/onboarding/update-policy'; import { db } from '@db'; -import { tasks } from '@trigger.dev/sdk'; +import { auth, tasks } from '@trigger.dev/sdk'; import { z } from 'zod'; export const regeneratePolicyAction = authActionClient @@ -27,6 +27,15 @@ export const regeneratePolicyAction = authActionClient throw new Error('No active organization'); } + // Get the member ID for the user triggering the regeneration + const member = await db.member.findFirst({ + where: { + organizationId: session.activeOrganizationId, + userId: session.userId, + }, + select: { id: true }, + }); + // Load frameworks associated to this organization via instances const instances = await db.frameworkInstance.findMany({ where: { organizationId: session.activeOrganizationId }, @@ -54,13 +63,27 @@ export const regeneratePolicyAction = authActionClient }); const contextHub = contextEntries.map((c) => `${c.question}\n${c.answer}`).join('\n'); - await tasks.trigger('update-policy', { + const handle = await tasks.trigger('update-policy', { organizationId: session.activeOrganizationId, policyId, contextHub, frameworks: uniqueFrameworks, + memberId: member?.id, + }); + + // Create a public access token for real-time tracking + const publicAccessToken = await auth.createPublicToken({ + scopes: { + read: { + runs: [handle.id], + }, + }, }); // Revalidation handled by safe-action middleware using x-pathname header - return { success: true }; + return { + success: true, + runId: handle.id, + publicAccessToken, + }; }); 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 fba7e961b..23f06d603 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 @@ -5,11 +5,11 @@ import { BUCKET_NAME, s3Client } from '@/app/s3'; import { DeleteObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; import { db, PolicyDisplayFormat } from '@db'; import { revalidatePath } from 'next/cache'; -import { headers } from 'next/headers'; import { z } from 'zod'; const uploadPolicyPdfSchema = z.object({ policyId: z.string(), + versionId: z.string().optional(), // If provided, upload to this version fileName: z.string(), fileType: z.string(), fileData: z.string(), // Base64 encoded file content @@ -25,7 +25,7 @@ export const uploadPolicyPdfAction = authActionClient }, }) .action(async ({ parsedInput, ctx }) => { - const { policyId, fileName, fileType, fileData } = parsedInput; + const { policyId, versionId, fileName, fileType, fileData } = parsedInput; const { session } = ctx; const organizationId = session.activeOrganizationId; @@ -37,19 +37,82 @@ export const uploadPolicyPdfAction = authActionClient return { success: false, error: 'File storage is not configured.' }; } - const sanitizedFileName = fileName.replace(/[^a-zA-Z0-9.-]/g, '_'); - const s3Key = `${organizationId}/policies/${policyId}/${Date.now()}-${sanitizedFileName}`; - try { - // 1. Get the existing policy to check for an old PDF - const existingPolicy = await db.policy.findUnique({ + // Verify policy belongs to organization + const policy = await db.policy.findUnique({ where: { id: policyId, organizationId }, - select: { pdfUrl: true }, + select: { + id: true, + pdfUrl: true, + currentVersionId: true, + pendingVersionId: true, + }, }); - const oldPdfUrl = existingPolicy?.pdfUrl; + if (!policy) { + return { success: false, error: 'Policy not found' }; + } + + let oldPdfUrl: string | null = null; + + if (versionId) { + // Upload to specific version + const version = await db.policyVersion.findUnique({ + where: { id: versionId }, + select: { id: true, policyId: true, pdfUrl: true, version: true }, + }); + + if (!version || version.policyId !== policyId) { + return { success: false, error: 'Version not found' }; + } + + // Don't allow uploading PDF to published or pending versions + if (version.id === policy.currentVersionId) { + return { success: false, error: 'Cannot upload PDF to the published version' }; + } + if (version.id === policy.pendingVersionId) { + return { success: false, error: 'Cannot upload PDF to a version pending approval' }; + } + + oldPdfUrl = version.pdfUrl; + + const sanitizedFileName = fileName.replace(/[^a-zA-Z0-9.-]/g, '_'); + const s3Key = `${organizationId}/policies/${policyId}/v${version.version}-${Date.now()}-${sanitizedFileName}`; + + // Upload to S3 + const fileBuffer = Buffer.from(fileData, 'base64'); + const putCommand = new PutObjectCommand({ + Bucket: BUCKET_NAME, + Key: s3Key, + Body: fileBuffer, + ContentType: fileType, + }); + await s3Client.send(putCommand); + + // Update version + await db.policyVersion.update({ + where: { id: versionId }, + data: { pdfUrl: s3Key }, + }); + + // Delete old PDF if it exists and is different + if (oldPdfUrl && oldPdfUrl !== s3Key) { + try { + await s3Client.send(new DeleteObjectCommand({ Bucket: BUCKET_NAME, Key: oldPdfUrl })); + } catch (error) { + console.error('Error cleaning up old version PDF from S3:', error); + } + } + + revalidatePath(`/${organizationId}/policies/${policyId}`); + return { success: true, data: { s3Key } }; + } + + // Legacy: upload to policy level + oldPdfUrl = policy.pdfUrl; + const sanitizedFileName = fileName.replace(/[^a-zA-Z0-9.-]/g, '_'); + const s3Key = `${organizationId}/policies/${policyId}/${Date.now()}-${sanitizedFileName}`; - // 2. Upload the new file to S3 const fileBuffer = Buffer.from(fileData, 'base64'); const putCommand = new PutObjectCommand({ Bucket: BUCKET_NAME, @@ -57,10 +120,8 @@ export const uploadPolicyPdfAction = authActionClient Body: fileBuffer, ContentType: fileType, }); - await s3Client.send(putCommand); - // 3. Update the database to point to the new S3 key await db.policy.update({ where: { id: policyId, organizationId }, data: { @@ -69,25 +130,15 @@ export const uploadPolicyPdfAction = authActionClient }, }); - // 4. Delete the old PDF from S3 (cleanup) - if (oldPdfUrl && oldPdfUrl !== s3Key && s3Client && BUCKET_NAME) { + if (oldPdfUrl && oldPdfUrl !== s3Key) { try { - const deleteCommand = new DeleteObjectCommand({ - Bucket: BUCKET_NAME, - Key: oldPdfUrl, - }); - await s3Client.send(deleteCommand); + await s3Client.send(new DeleteObjectCommand({ Bucket: BUCKET_NAME, Key: oldPdfUrl })); } catch (error) { - // Log cleanup error but the main task (uploading new) was successful console.error('Error cleaning up old policy PDF from S3:', error); } } - const headersList = await headers(); - let path = headersList.get('x-pathname') || headersList.get('referer') || ''; - path = path.replace(/\/[a-z]{2}\//, '/'); - revalidatePath(path); - + revalidatePath(`/${organizationId}/policies/${policyId}`); return { success: true, data: { s3Key } }; } catch (error) { console.error('Error uploading policy PDF to S3:', error); 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 ff622ff79..8b4c7cfcf 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 @@ -9,7 +9,6 @@ import { AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, - AlertDialogTrigger, Button, Card, CardContent, @@ -40,16 +39,35 @@ import { deletePolicyPdfAction } from '../actions/delete-policy-pdf'; interface PdfViewerProps { policyId: string; + versionId?: string; // The version ID for version-specific operations pdfUrl?: string | null; // This prop contains the S3 Key isPendingApproval: boolean; + /** Whether the current version is read-only (published or pending) */ + isVersionReadOnly?: boolean; + /** Whether viewing the currently active/published version */ + isViewingActiveVersion?: boolean; + /** Whether viewing a version pending approval */ + isViewingPendingVersion?: boolean; onMutate?: () => void; } -export function PdfViewer({ policyId, pdfUrl, isPendingApproval, onMutate }: PdfViewerProps) { +export function PdfViewer({ + policyId, + versionId, + pdfUrl, + isPendingApproval, + isVersionReadOnly = false, + isViewingActiveVersion = false, + isViewingPendingVersion = false, + onMutate +}: PdfViewerProps) { + // Combine both checks - can't modify if pending approval OR version is read-only + const isReadOnly = isPendingApproval || isVersionReadOnly; const router = useRouter(); const [files, setFiles] = useState([]); const [signedUrl, setSignedUrl] = useState(null); const [isUrlLoading, setUrlLoading] = useState(true); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const fileInputRef = useRef(null); const { execute: getUrl } = useAction(getPolicyPdfUrlAction, { @@ -68,11 +86,15 @@ export function PdfViewer({ policyId, pdfUrl, isPendingApproval, onMutate }: Pdf // Fetch the secure, temporary URL when the component loads with an S3 key. useEffect(() => { if (pdfUrl) { - getUrl({ policyId }); + setUrlLoading(true); + setSignedUrl(null); // Reset before fetching + getUrl({ policyId, versionId }); } else { + // No PDF for this version - reset state + setSignedUrl(null); setUrlLoading(false); } - }, [pdfUrl, policyId, getUrl]); + }, [pdfUrl, policyId, versionId, getUrl]); const { execute: upload, status: uploadStatus } = useAction(uploadPolicyPdfAction, { onSuccess: () => { @@ -123,6 +145,7 @@ export function PdfViewer({ policyId, pdfUrl, isPendingApproval, onMutate }: Pdf const base64Data = (reader.result as string).split(',')[1]; upload({ policyId, + versionId, fileName: file.name, fileType: file.type, fileData: base64Data, @@ -186,7 +209,7 @@ export function PdfViewer({ policyId, pdfUrl, isPendingApproval, onMutate }: Pdf )} - {pdfUrl && !isPendingApproval && ( + {pdfUrl && !isReadOnly && ( <> Replace - - - - - Delete - - - - - Delete PDF? - - Are you sure you want to delete this PDF? This action cannot be undone. - The policy will switch back to Editor View. - - - - Cancel - deletePdf({ policyId })} - variant="destructive" - loading={isDeleting} - > - Delete - - - - + setIsDeleteDialogOpen(true)} + > + + Delete + + + + + + Delete PDF? + + Are you sure you want to delete this PDF? This action cannot be undone. + The policy will switch back to Editor View. + + + + Cancel + { + deletePdf({ policyId, versionId }); + setIsDeleteDialogOpen(false); + }} + variant="destructive" + loading={isDeleting} + > + Delete + + + + )}
+ {isVersionReadOnly && pdfUrl && ( +
+ + {isViewingPendingVersion + ? 'This version is pending approval and cannot be edited.' + : isViewingActiveVersion + ? 'This version is published. Create a new version to make changes.' + : 'This version cannot be edited.'} + +
+ )} {pdfUrl ? (
{isUrlLoading ? ( @@ -281,7 +318,7 @@ export function PdfViewer({ policyId, pdfUrl, isPendingApproval, onMutate }: Pdf

)} - {!isPendingApproval && ( + {!isReadOnly && ( )}
- ) : !isPendingApproval ? ( + ) : !isReadOnly ? (

No PDF Uploaded

- A PDF document is required for this policy. + {isReadOnly + ? 'This version does not have a PDF document. The policy content is displayed in the Editor view.' + : 'No PDF has been uploaded for this policy. Upload a PDF to display it here, or use the Editor view to edit content.'}

)} diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyHeaderActions.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyHeaderActions.tsx index faacf710c..7cd0a66b9 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyHeaderActions.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyHeaderActions.tsx @@ -1,5 +1,6 @@ 'use client'; +import { getPolicyPdfUrlAction } from '@/app/(app)/[orgId]/policies/[policyId]/actions/get-policy-pdf-url'; import { regeneratePolicyAction } from '@/app/(app)/[orgId]/policies/[policyId]/actions/regenerate-policy'; import { generatePolicyPDF } from '@/lib/pdf-generator'; import { Button } from '@comp/ui/button'; @@ -19,27 +20,89 @@ import { DropdownMenuTrigger, } from '@comp/ui/dropdown-menu'; import { Icons } from '@comp/ui/icons'; -import type { Policy, Member, User } from '@db'; +import type { Member, Policy, PolicyVersion, User } from '@db'; import type { JSONContent } from '@tiptap/react'; -import { useRouter } from 'next/navigation'; +import { useRealtimeRun } from '@trigger.dev/react-hooks'; import { useAction } from 'next-safe-action/hooks'; -import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { useEffect, useRef, useState } from 'react'; import { toast } from 'sonner'; import { AuditLogWithRelations } from '../data'; -export function PolicyHeaderActions({ +type PolicyWithVersion = Policy & { + approver: (Member & { user: User }) | null; + currentVersion?: PolicyVersion | null; +}; + +export function PolicyHeaderActions({ policy, - logs -}: { - policy: (Policy & { approver: (Member & { user: User }) | null }) | null; + logs, +}: { + policy: PolicyWithVersion | null; logs: AuditLogWithRelations[]; }) { const router = useRouter(); const [isRegenerateConfirmOpen, setRegenerateConfirmOpen] = useState(false); + const [isDownloading, setIsDownloading] = useState(false); + const [isRegenerating, setIsRegenerating] = useState(false); + + // Real-time task tracking + const [runInfo, setRunInfo] = useState<{ + runId: string; + accessToken: string; + } | null>(null); + const toastIdRef = useRef(null); + + // Subscribe to run status when we have a runId + const { run } = useRealtimeRun(runInfo?.runId ?? '', { + accessToken: runInfo?.accessToken ?? '', + enabled: !!runInfo?.runId && !!runInfo?.accessToken, + }); + + // Handle run completion + useEffect(() => { + if (!run) return; + + if (run.status === 'COMPLETED') { + if (toastIdRef.current) { + toast.dismiss(toastIdRef.current); + } + toast.success('Policy content updated!'); + setIsRegenerating(false); + setRunInfo(null); + toastIdRef.current = null; + router.refresh(); + } else if (run.status === 'FAILED' || run.status === 'CRASHED' || run.status === 'CANCELED') { + if (toastIdRef.current) { + toast.dismiss(toastIdRef.current); + } + toast.error('Policy regeneration failed'); + setIsRegenerating(false); + setRunInfo(null); + toastIdRef.current = null; + } + }, [run, router]); + // Delete flows through query param to existing dialog in PolicyOverview const regenerate = useAction(regeneratePolicyAction, { - onSuccess: () => toast.success('Regeneration triggered. This may take a moment.'), - onError: () => toast.error('Failed to trigger policy regeneration'), + onSuccess: (result) => { + if (result.data?.runId && result.data?.publicAccessToken) { + // Show loading toast + const toastId = toast.loading('Regenerating policy content...'); + toastIdRef.current = toastId; + setIsRegenerating(true); + + // Start tracking the run + setRunInfo({ + runId: result.data.runId, + accessToken: result.data.publicAccessToken, + }); + } + }, + onError: () => { + toast.error('Failed to trigger policy regeneration'); + setIsRegenerating(false); + }, }); const updateQueryParam = ({ key, value }: { key: string; value: string }) => { @@ -48,19 +111,53 @@ export function PolicyHeaderActions({ router.push(`${url.pathname}?${url.searchParams.toString()}`); }; - const handleDownloadPDF = () => { + const handleDownloadPDF = async () => { + if (!policy) { + toast.error('Policy not available'); + return; + } + + setIsDownloading(true); + try { - if (!policy || !policy.content) { + // Check if the published version has a PDF uploaded + const publishedVersionPdfUrl = policy.currentVersion?.pdfUrl; + + if (publishedVersionPdfUrl) { + // Download the uploaded PDF directly + const result = await getPolicyPdfUrlAction({ + policyId: policy.id, + versionId: policy.currentVersion?.id, + }); + + if (result?.data?.success && result.data.data) { + // Create a temporary link and trigger download + const link = document.createElement('a'); + link.href = result.data.data; // data is the signed URL string + link.download = `${policy.name || 'Policy'}.pdf`; + link.target = '_blank'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + return; + } + } + + // Fall back to generating PDF from content + // Use published version content if available, otherwise policy content + const contentSource = policy.currentVersion?.content ?? policy.content; + + if (!contentSource) { toast.error('Policy content not available for download'); return; } - // Convert policy content to JSONContent array if needed + // Convert content to JSONContent array let policyContent: JSONContent[]; - if (Array.isArray(policy.content)) { - policyContent = policy.content as JSONContent[]; - } else if (typeof policy.content === 'object' && policy.content !== null) { - policyContent = [policy.content as JSONContent]; + if (Array.isArray(contentSource)) { + policyContent = contentSource as JSONContent[]; + } else if (typeof contentSource === 'object' && contentSource !== null) { + policyContent = [contentSource as JSONContent]; } else { toast.error('Invalid policy content format'); return; @@ -71,6 +168,8 @@ export function PolicyHeaderActions({ } catch (error) { console.error('Error downloading policy PDF:', error); toast.error('Failed to generate policy PDF'); + } finally { + setIsDownloading(false); } }; @@ -87,8 +186,12 @@ export function PolicyHeaderActions({ - setRegenerateConfirmOpen(true)} disabled={isPendingApproval}> - Regenerate policy + setRegenerateConfirmOpen(true)} + disabled={isPendingApproval || isRegenerating} + > + {' '} + {isRegenerating ? 'Regenerating...' : 'Regenerate policy'} { @@ -98,10 +201,9 @@ export function PolicyHeaderActions({ Edit policy - handleDownloadPDF()} - > - Download as PDF + handleDownloadPDF()} disabled={isDownloading}> + {' '} + {isDownloading ? 'Downloading...' : 'Download as PDF'} { @@ -127,8 +229,9 @@ export function PolicyHeaderActions({ Regenerate Policy - This will generate new policy content using your org context and frameworks and mark - it for review. Continue? + This will generate new policy content using your org context and frameworks. It will + delete all existing versions and their PDFs for this policy. This cannot be undone. + Continue? diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyPage.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyPage.tsx index 629ba9b90..0c62f2be3 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyPage.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyPage.tsx @@ -1,7 +1,11 @@ -import type { Control, Member, Policy, User } from '@db'; +import type { Control, Member, Policy, PolicyVersion, User } from '@db'; import type { AuditLogWithRelations } from '../data'; import { PolicyPageTabs } from './PolicyPageTabs'; +type PolicyVersionWithPublisher = PolicyVersion & { + publishedBy: (Member & { user: User }) | null; +}; + export default function PolicyPage({ policy, assignees, @@ -11,6 +15,7 @@ export default function PolicyPage({ policyId, organizationId, logs, + versions, showAiAssistant, }: { policy: (Policy & { approver: (Member & { user: User }) | null }) | null; @@ -21,6 +26,7 @@ export default function PolicyPage({ policyId: string; organizationId: string; logs: AuditLogWithRelations[]; + versions: PolicyVersionWithPublisher[]; showAiAssistant: boolean; }) { return ( @@ -33,6 +39,7 @@ export default function PolicyPage({ allControls={allControls} isPendingApproval={isPendingApproval} logs={logs} + versions={versions} showAiAssistant={showAiAssistant} /> ); diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyPageTabs.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyPageTabs.tsx index 348d2492a..ebe3fd499 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyPageTabs.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyPageTabs.tsx @@ -1,9 +1,10 @@ 'use client'; -import type { Control, Member, Policy, User } from '@db'; +import type { Control, Member, Policy, PolicyVersion, User } from '@db'; import type { JSONContent } from '@tiptap/react'; import { Stack, Tabs, TabsContent, TabsList, TabsTrigger } from '@trycompai/design-system'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import { useMemo } from 'react'; import { Comments } from '../../../../../../components/comments/Comments'; import type { AuditLogWithRelations } from '../data'; import { PolicyContentManager } from '../editor/components/PolicyDetails'; @@ -16,6 +17,10 @@ import { PolicyOverviewSheet } from './PolicyOverviewSheet'; import { PolicySettingsCard } from './PolicySettingsCard'; import { RecentAuditLogs } from './RecentAuditLogs'; +type PolicyVersionWithPublisher = PolicyVersion & { + publishedBy: (Member & { user: User }) | null; +}; + interface PolicyPageTabsProps { policy: (Policy & { approver: (Member & { user: User }) | null }) | null; assignees: (Member & { user: User })[]; @@ -25,6 +30,7 @@ interface PolicyPageTabsProps { policyId: string; organizationId: string; logs: AuditLogWithRelations[]; + versions: PolicyVersionWithPublisher[]; showAiAssistant: boolean; } @@ -37,6 +43,7 @@ export function PolicyPageTabs({ policyId, organizationId, logs, + versions, showAiAssistant, }: PolicyPageTabsProps) { const router = useRouter(); @@ -50,6 +57,13 @@ export function PolicyPageTabs({ initialData: initialPolicy, }); + const hasDraftChanges = useMemo(() => { + if (!policy) return false; + const draftContent = policy.draftContent ?? []; + const publishedContent = policy.content ?? []; + return JSON.stringify(draftContent) !== JSON.stringify(publishedContent); + }, [policy]); + // Derive isPendingApproval from current policy data const isPendingApproval = policy ? !!policy.approverId : initialIsPendingApproval; @@ -82,6 +96,7 @@ export function PolicyPageTabs({ policy={policy} assignees={assignees} isPendingApproval={isPendingApproval} + versions={versions} onMutate={mutate} /> { + // Find the published version content + const currentVersion = versions.find((v) => v.id === policy?.currentVersionId); + if (currentVersion?.content) { + const versionContent = currentVersion.content as JSONContent[]; + return Array.isArray(versionContent) ? versionContent : [versionContent]; + } + // Fallback to legacy policy.content for backward compatibility + if (policy?.content) { + return policy.content as JSONContent[]; + } + return []; + })() + } displayFormat={policy?.displayFormat} - pdfUrl={policy?.pdfUrl} + pdfUrl={ + // Use version PDF if available, otherwise fallback to policy PDF + versions.find((v) => v.id === policy?.currentVersionId)?.pdfUrl ?? policy?.pdfUrl + } aiAssistantEnabled={showAiAssistant} + hasUnpublishedChanges={hasDraftChanges} + currentVersionNumber={ + versions.find((v) => v.id === policy?.currentVersionId)?.version ?? null + } + currentVersionId={policy?.currentVersionId ?? null} + pendingVersionId={policy?.pendingVersionId ?? null} + versions={versions} onMutate={mutate} /> diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicySettingsCard.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicySettingsCard.tsx index f0594a1e5..9680f6b33 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicySettingsCard.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicySettingsCard.tsx @@ -1,13 +1,18 @@ 'use client'; -import type { Member, Policy, User } from '@db'; +import type { Member, Policy, PolicyVersion, User } from '@db'; import { Section } from '@trycompai/design-system'; import { UpdatePolicyOverview } from './UpdatePolicyOverview'; +type PolicyVersionWithPublisher = PolicyVersion & { + publishedBy: (Member & { user: User }) | null; +}; + interface PolicySettingsCardProps { policy: (Policy & { approver: (Member & { user: User }) | null }) | null; assignees: (Member & { user: User })[]; isPendingApproval: boolean; + versions?: PolicyVersionWithPublisher[]; onMutate?: () => void; } @@ -15,6 +20,7 @@ export function PolicySettingsCard({ policy, assignees, isPendingApproval, + versions = [], onMutate, }: PolicySettingsCardProps) { if (!policy) { @@ -24,10 +30,11 @@ export function PolicySettingsCard({ return (
diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PublishVersionDialog.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PublishVersionDialog.tsx new file mode 100644 index 000000000..ebadfcf14 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PublishVersionDialog.tsx @@ -0,0 +1,114 @@ +'use client'; + +import { createVersionAction } from '@/actions/policies/create-version'; +import { Button } from '@comp/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@comp/ui/dialog'; +import { Label } from '@comp/ui/label'; +import { Textarea } from '@comp/ui/textarea'; +import { Stack } from '@trycompai/design-system'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; +import { toast } from 'sonner'; + +interface PublishVersionDialogProps { + policyId: string; + currentVersionNumber?: number; // The published version number for display + isOpen: boolean; + onClose: () => void; + onSuccess?: (newVersionId: string) => void; +} + +export function PublishVersionDialog({ + policyId, + currentVersionNumber, + isOpen, + onClose, + onSuccess, +}: PublishVersionDialogProps) { + const router = useRouter(); + const [changelog, setChangelog] = useState(''); + const [isCreating, setIsCreating] = useState(false); + + const handleCreate = async () => { + setIsCreating(true); + + try { + const result = await createVersionAction({ + policyId, + changelog: changelog.trim() || undefined, + entityId: policyId, + }); + + if (!result?.data?.success) { + throw new Error(result?.data?.error || 'Failed to create version'); + } + + const newVersionId = result.data.data?.versionId; + toast.success(`Created version ${result.data.data?.version} as draft`); + setChangelog(''); + onClose(); + if (newVersionId) { + onSuccess?.(newVersionId); + } + router.refresh(); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to create version'); + } finally { + setIsCreating(false); + } + }; + + const handleOpenChange = (open: boolean) => { + if (!open) { + setChangelog(''); + onClose(); + } + }; + + return ( + + + + Create New Version + + {currentVersionNumber + ? `This will create a new version based on the published version (v${currentVersionNumber}). ` + : 'This will create a new version from the current policy content. '} + The new version will be saved as a draft and can be published through the approval workflow. + + + +
+ +
+ +