From 5fdb4482ddd414b31aebb6278cf5d4a82a5b8bc9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:10:40 -0500 Subject: [PATCH 1/7] fix(app): enable capitalized text for role in csv when adding users (#2123) Co-authored-by: chasprowebdev Co-authored-by: Mariano Fuentes --- .../(app)/[orgId]/people/all/components/InviteMembersModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/InviteMembersModal.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/InviteMembersModal.tsx index 97443acc5..910b26848 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/InviteMembersModal.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/InviteMembersModal.tsx @@ -331,7 +331,7 @@ export function InviteMembersModal({ } // Validate role(s) - split by pipe for multiple roles - const roles = roleValue.split('|').map((r) => r.trim()); + const roles = roleValue.split('|').map((r) => r.trim().toLowerCase()); const validRoles = roles.filter((role) => isInviteRole(role, normalizedAllowedRoles)); if (validRoles.length === 0) { From c2a552835d4447e6c99df08f6a62e3e42e18629e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:16:40 -0500 Subject: [PATCH 2/7] CS-60 [Improvement] [Feature] - Send onboarding email for new employee (#2102) * feat(app): send onboarding email for new employee * fix(app): update NEXT_PUBLIC_PORTAL_URL in .env.example * fix(app): employee onboarding email failure after member created causes inconsistent state * fix(app): rebuilt the invite link sent to the new employee * fix(app): remove the unused variables in addEmployeeWithoutInvite.ts * fix(app): failed employee additions silently counted as successful --------- Co-authored-by: chasprowebdev Co-authored-by: Mariano Fuentes Co-authored-by: chasprowebdev <70908289+chasprowebdev@users.noreply.github.com> --- apps/app/.env.example | 2 +- .../all/actions/addEmployeeWithoutInvite.ts | 36 ++++++++++++++- .../all/components/InviteMembersModal.tsx | 44 +++++++++++++++++-- 3 files changed, 76 insertions(+), 6 deletions(-) diff --git a/apps/app/.env.example b/apps/app/.env.example index 680205be6..9b08a9bb2 100644 --- a/apps/app/.env.example +++ b/apps/app/.env.example @@ -44,7 +44,7 @@ APP_AWS_ENDPOINT="" # optional for using services like MinIO REVALIDATION_SECRET="" # Revalidate server side, generate something random GROQ_API_KEY="" # For the AI chat, on dashboard -NEXT_PUBLIC_PORTAL_URL="http://localhost:3001" +NEXT_PUBLIC_PORTAL_URL="http://localhost:3002" ANTHROPIC_API_KEY="" # Optional, For more options with models BETTER_AUTH_URL=http://localhost:3000 # For auth diff --git a/apps/app/src/app/(app)/[orgId]/people/all/actions/addEmployeeWithoutInvite.ts b/apps/app/src/app/(app)/[orgId]/people/all/actions/addEmployeeWithoutInvite.ts index 8d72f710b..68cbd7fc9 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/actions/addEmployeeWithoutInvite.ts +++ b/apps/app/src/app/(app)/[orgId]/people/all/actions/addEmployeeWithoutInvite.ts @@ -2,6 +2,7 @@ import { createTrainingVideoEntries } from '@/lib/db/employee'; import { auth } from '@/utils/auth'; +import { sendInviteMemberEmail } from '@comp/email'; import type { Role } from '@db'; import { db } from '@db'; import { headers } from 'next/headers'; @@ -48,6 +49,16 @@ export const addEmployeeWithoutInvite = async ({ } } + // Get organization name + const organization = await db.organization.findUnique({ + where: { id: organizationId }, + select: { name: true }, + }); + + if (!organization) { + throw new Error('Organization not found.'); + } + let userId = ''; const existingUser = await db.user.findFirst({ where: { @@ -112,7 +123,30 @@ export const addEmployeeWithoutInvite = async ({ await createTrainingVideoEntries(member.id); } - return { success: true, data: member }; + // Generate invite link + const inviteLink = `${process.env.NEXT_PUBLIC_PORTAL_URL}/${organizationId}`; + + // Send the invitation email (non-fatal: member is already created) + let emailSent = true; + let emailError: string | undefined; + try { + await sendInviteMemberEmail({ + inviteeEmail: email.toLowerCase(), + inviteLink, + organizationName: organization.name, + }); + } catch (emailErr) { + emailSent = false; + emailError = emailErr instanceof Error ? emailErr.message : 'Failed to send invite email'; + console.error('Invite email failed after member was added:', { email, organizationId, error: emailErr }); + } + + return { + success: true, + data: member, + emailSent, + ...(emailError && { emailError }), + }; } catch (error) { console.error('Error adding employee:', error); return { success: false, error: 'Failed to add employee' }; diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/InviteMembersModal.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/InviteMembersModal.tsx index 910b26848..80e1c3f81 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/InviteMembersModal.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/InviteMembersModal.tsx @@ -162,6 +162,7 @@ export function InviteMembersModal({ // Process invitations let successCount = 0; const failedInvites: { email: string; error: string }[] = []; + const emailFailedEmails: string[] = []; // Process each invitation sequentially for (const invite of values.manualInvites) { @@ -170,11 +171,22 @@ export function InviteMembersModal({ (invite.roles.includes('employee') || invite.roles.includes('contractor')); try { if (hasEmployeeRoleAndNoAdmin) { - await addEmployeeWithoutInvite({ + const result = await addEmployeeWithoutInvite({ organizationId, email: invite.email.toLowerCase(), roles: invite.roles, }); + if (!result.success) { + failedInvites.push({ + email: invite.email, + error: result.error ?? 'Failed to add employee', + }); + } else { + if ('emailSent' in result && result.emailSent === false) { + emailFailedEmails.push(invite.email); + } + successCount++; + } } else { // Check member status and reactivate if needed const memberStatus = await checkMemberStatus({ @@ -197,8 +209,8 @@ export function InviteMembersModal({ roles: invite.roles, }); } + successCount++; } - successCount++; } catch (error) { console.error(`Failed to invite ${invite.email}:`, error); failedInvites.push({ @@ -226,6 +238,12 @@ export function InviteMembersModal({ `Failed to invite ${failedInvites.length} member(s): ${failedInvites.map((f) => f.email).join(', ')}`, ); } + + if (emailFailedEmails.length > 0) { + toast.warning( + `${emailFailedEmails.length} member(s) added but invite email could not be sent: ${emailFailedEmails.join(', ')}. You can resend from the team page.`, + ); + } } else if (values.mode === 'csv') { // Handle CSV file uploads console.log('Processing CSV mode'); @@ -305,6 +323,7 @@ export function InviteMembersModal({ // Track results let successCount = 0; const failedInvites: { email: string; error: string }[] = []; + const emailFailedEmails: string[] = []; // Process each row for (const row of dataRows) { @@ -348,11 +367,22 @@ export function InviteMembersModal({ !validRoles.includes('admin'); try { if (hasEmployeeRoleAndNoAdmin) { - await addEmployeeWithoutInvite({ + const result = await addEmployeeWithoutInvite({ organizationId, email: email.toLowerCase(), roles: validRoles, }); + if (!result.success) { + failedInvites.push({ + email, + error: result.error ?? 'Failed to add employee', + }); + } else { + if ('emailSent' in result && result.emailSent === false) { + emailFailedEmails.push(email); + } + successCount++; + } } else { // Check member status and reactivate if needed const memberStatus = await checkMemberStatus({ @@ -375,8 +405,8 @@ export function InviteMembersModal({ roles: validRoles, }); } + successCount++; } - successCount++; } catch (error) { console.error(`Failed to invite ${email}:`, error); failedInvites.push({ @@ -404,6 +434,12 @@ export function InviteMembersModal({ `Failed to invite ${failedInvites.length} member(s): ${failedInvites.map((f) => f.email).join(', ')}`, ); } + + if (emailFailedEmails.length > 0) { + toast.warning( + `${emailFailedEmails.length} member(s) added but invite email could not be sent: ${emailFailedEmails.join(', ')}. You can resend from the team page.`, + ); + } } catch (csvError) { console.error('Error parsing CSV:', csvError); toast.error('Failed to parse CSV file. Please check the format.'); From 346eb4c608703d952e23d1a62f1d76dfea7c87ab Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:18:10 -0500 Subject: [PATCH 3/7] CS-117 Remove screenshots on portal (#2095) * feat(portal): add a way to remove screenshots on portal * fix(portal): remove images from S3 when removing screenshots on portal * fix(app): fix the limit issue of S3 delete request * fix(app): return fail if S3 deletion fails * fix(portal): policy image reset modal should not be closed during deletion * fix(portal): reverse the operation order - delete DB records first, then S3 --------- Co-authored-by: chasprowebdev Co-authored-by: Mariano Fuentes Co-authored-by: chasprowebdev <70908289+chasprowebdev@users.noreply.github.com> --- .../tasks/DeviceAgentAccordionItem.tsx | 7 +- .../components/tasks/FleetPolicyItem.tsx | 20 ++++- .../tasks/PolicyImageResetModal.tsx | 82 +++++++++++++++++++ .../tasks/PolicyImageUploadModal.tsx | 13 +-- apps/portal/src/app/api/fleet-policy/route.ts | 74 ++++++++++++++++- 5 files changed, 187 insertions(+), 9 deletions(-) create mode 100644 apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageResetModal.tsx diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/DeviceAgentAccordionItem.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/DeviceAgentAccordionItem.tsx index d9975c867..70b141776 100644 --- a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/DeviceAgentAccordionItem.tsx +++ b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/DeviceAgentAccordionItem.tsx @@ -253,7 +253,12 @@ export function DeviceAgentAccordionItem({ {fleetPolicies.length > 0 ? ( <> {fleetPolicies.map((policy) => ( - + ))} ) : ( diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/FleetPolicyItem.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/FleetPolicyItem.tsx index fb97c810f..1f2b5f93f 100644 --- a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/FleetPolicyItem.tsx +++ b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/FleetPolicyItem.tsx @@ -5,7 +5,7 @@ import { useMemo, useState } from 'react'; import { Button } from '@comp/ui/button'; import { cn } from '@comp/ui/cn'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@comp/ui/tooltip'; -import { CheckCircle2, HelpCircle, Image, MoreVertical, Upload, XCircle } from 'lucide-react'; +import { CheckCircle2, HelpCircle, Image, MoreVertical, Trash, Upload, XCircle } from 'lucide-react'; import { DropdownMenu, DropdownMenuContent, @@ -15,15 +15,18 @@ import { import type { FleetPolicy } from '../../types'; import { PolicyImageUploadModal } from './PolicyImageUploadModal'; import { PolicyImagePreviewModal } from './PolicyImagePreviewModal'; +import { PolicyImageResetModal } from './PolicyImageResetModal'; interface FleetPolicyItemProps { policy: FleetPolicy; + organizationId: string; onRefresh: () => void; } -export function FleetPolicyItem({ policy, onRefresh }: FleetPolicyItemProps) { +export function FleetPolicyItem({ policy, organizationId, onRefresh }: FleetPolicyItemProps) { const [isUploadOpen, setIsUploadOpen] = useState(false); const [isPreviewOpen, setIsPreviewOpen] = useState(false); + const [isRemoveOpen, setIsRemoveOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false); const actions = useMemo(() => { @@ -35,6 +38,11 @@ export function FleetPolicyItem({ policy, onRefresh }: FleetPolicyItemProps) { renderIcon: () => , onClick: () => setIsPreviewOpen(true), }, + { + label: 'Remove images', + renderIcon: () => , + onClick: () => setIsRemoveOpen(true), + }, ]; } @@ -131,6 +139,7 @@ export function FleetPolicyItem({ policy, onRefresh }: FleetPolicyItemProps) { + ); } \ No newline at end of file diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageResetModal.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageResetModal.tsx new file mode 100644 index 000000000..ea510dc71 --- /dev/null +++ b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageResetModal.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { useState } from 'react'; + +import { Button } from '@comp/ui/button'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@comp/ui/dialog'; +import { Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; + +interface PolicyImageResetModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + organizationId: string; + policyId: number; + onRefresh: () => void; +} + +export function PolicyImageResetModal({ + open, + onOpenChange, + organizationId, + policyId, + onRefresh, +}: PolicyImageResetModalProps) { + const [isDeleting, setIsDeleting] = useState(false); + + const handleConfirm = async () => { + setIsDeleting(true); + try { + const params = new URLSearchParams({ organizationId, policyId: String(policyId) }); + const res = await fetch(`/api/fleet-policy?${params}`, { method: 'DELETE' }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data?.error ?? 'Failed to remove images'); + } + onRefresh(); + onOpenChange(false); + toast.success('Images removed'); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to remove images'); + } finally { + setIsDeleting(false); + } + }; + + return ( + { + if (!isDeleting || nextOpen) onOpenChange(nextOpen); + }} + > + + + Remove all images + +

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

+ + + + +
+
+ ); +} diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageUploadModal.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageUploadModal.tsx index 7530dac6f..5f04e63a9 100644 --- a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageUploadModal.tsx +++ b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/PolicyImageUploadModal.tsx @@ -12,24 +12,27 @@ import { } from '@comp/ui/dialog'; import { ImagePlus, Trash2, Loader2 } from 'lucide-react'; import Image from 'next/image'; -import { useParams } from 'next/navigation'; import { toast } from 'sonner'; import { FleetPolicy } from '../../types'; interface PolicyImageUploadModalProps { open: boolean; policy: FleetPolicy; + organizationId: string; onOpenChange: (open: boolean) => void; onRefresh: () => void; } -export function PolicyImageUploadModal({ open, policy, onOpenChange, onRefresh }: PolicyImageUploadModalProps) { +export function PolicyImageUploadModal({ + open, + policy, + organizationId, + onOpenChange, + onRefresh, +}: PolicyImageUploadModalProps) { const fileInputRef = useRef(null); const [files, setFiles] = useState>([]); const [isLoading, setIsLoading] = useState(false); - const params = useParams<{ orgId: string }>(); - const orgIdParam = params?.orgId; - const organizationId = Array.isArray(orgIdParam) ? orgIdParam[0] : orgIdParam; const handleFileChange = (e: React.ChangeEvent) => { const selected = Array.from(e.target.files ?? []); diff --git a/apps/portal/src/app/api/fleet-policy/route.ts b/apps/portal/src/app/api/fleet-policy/route.ts index 906bfde58..0b94e2692 100644 --- a/apps/portal/src/app/api/fleet-policy/route.ts +++ b/apps/portal/src/app/api/fleet-policy/route.ts @@ -1,7 +1,7 @@ import { auth } from '@/app/lib/auth'; import { validateMemberAndOrg } from '@/app/api/download-agent/utils'; import { APP_AWS_ORG_ASSETS_BUCKET, s3Client } from '@/utils/s3'; -import { GetObjectCommand } from '@aws-sdk/client-s3'; +import { DeleteObjectsCommand, GetObjectCommand } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { db } from '@db'; import { NextRequest, NextResponse } from 'next/server'; @@ -61,3 +61,75 @@ export async function GET(req: NextRequest) { return NextResponse.json({ success: true, data: withSignedUrls }); } + +export async function DELETE(req: NextRequest) { + const organizationId = req.nextUrl.searchParams.get('organizationId'); + const policyIdParam = req.nextUrl.searchParams.get('policyId'); + + if (!organizationId) { + return NextResponse.json({ error: 'No organization ID' }, { status: 400 }); + } + + const policyId = policyIdParam ? parseInt(policyIdParam, 10) : NaN; + if (Number.isNaN(policyId)) { + return NextResponse.json({ error: 'Invalid or missing policy ID' }, { status: 400 }); + } + + const session = await auth.api.getSession({ headers: req.headers }); + + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const member = await validateMemberAndOrg(session.user.id, organizationId); + if (!member) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const where = { + organizationId, + fleetPolicyId: policyId, + userId: session.user.id, + }; + + const recordsToDelete = await db.fleetPolicyResult.findMany({ + where, + select: { attachments: true }, + }); + + const allKeys = recordsToDelete.flatMap((r) => r.attachments ?? []).filter(Boolean); + + // Delete DB records first to avoid inconsistent state: if we deleted S3 first and + // DB delete fails, we'd have broken image links. Orphaned S3 objects are preferable. + const result = await db.fleetPolicyResult.deleteMany({ where }); + + const S3_DELETE_MAX_KEYS = 1000; + if (s3Client && APP_AWS_ORG_ASSETS_BUCKET && allKeys.length > 0) { + try { + for (let i = 0; i < allKeys.length; i += S3_DELETE_MAX_KEYS) { + const batch = allKeys.slice(i, i + S3_DELETE_MAX_KEYS); + await s3Client.send( + new DeleteObjectsCommand({ + Bucket: APP_AWS_ORG_ASSETS_BUCKET, + Delete: { + Objects: batch.map((key) => ({ Key: key })), + }, + }), + ); + } + } catch (error) { + // DB is already clean; orphaned S3 objects are acceptable and can be cleaned up later + console.error('Failed to delete policy attachment objects from S3 (orphaned)', { + error, + policyId, + organizationId, + keyCount: allKeys.length, + }); + } + } + + return NextResponse.json({ + success: true, + deletedCount: result.count, + }); +} From 5fab9bd703d1d42925b609631acf9e0f058cdf4f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 11 Feb 2026 16:00:52 -0500 Subject: [PATCH 4/7] fix(app): check DNS records using Node's built-in DNS instead of using external APIs (#2126) Co-authored-by: chasprowebdev --- .../actions/check-dns-record.ts | 95 ++++++------------- 1 file changed, 29 insertions(+), 66 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/actions/check-dns-record.ts b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/actions/check-dns-record.ts index 377463f19..7e49099fa 100644 --- a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/actions/check-dns-record.ts +++ b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/actions/check-dns-record.ts @@ -4,9 +4,12 @@ import { authActionClient } from '@/actions/safe-action'; import { env } from '@/env.mjs'; import { db } from '@db'; import { Vercel } from '@vercel/sdk'; +import * as dns from 'node:dns'; import { revalidatePath, revalidateTag } from 'next/cache'; import { z } from 'zod'; +const dnsPromises = dns.promises; + /** * Strict pattern to match known Vercel DNS CNAME targets. * Matches formats like: @@ -57,34 +60,17 @@ export const checkDnsRecordAction = authActionClient const rootDomain = domain.split('.').slice(-2).join('.'); const activeOrgId = ctx.session.activeOrganizationId; - const response = await fetch(`https://networkcalc.com/api/dns/lookup/${domain}`); - const txtResponse = await fetch( - `https://networkcalc.com/api/dns/lookup/${rootDomain}?type=TXT`, - ); - const vercelTxtResponse = await fetch( - `https://networkcalc.com/api/dns/lookup/_vercel.${rootDomain}?type=TXT`, - ); - - const data = await response.json(); - const txtData = await txtResponse.json(); - const vercelTxtData = await vercelTxtResponse.json(); - - if ( - response.status !== 200 || - data.status !== 'OK' || - txtResponse.status !== 200 || - txtData.status !== 'OK' - ) { - console.error('DNS lookup failed:', data); - throw new Error( - data.message || - 'DNS record verification failed, check the records are valid or try again later.', - ); - } - - const cnameRecords = data.records?.CNAME; - const txtRecords = txtData.records?.TXT; - const vercelTxtRecords = vercelTxtData.records?.TXT; + // Use Node's built-in DNS (no HTTPS) to avoid SSL/certificate issues with external APIs + const getCnameRecords = (host: string): Promise => + dnsPromises.resolve(host, 'CNAME').catch(() => []); + const getTxtRecords = (host: string): Promise => + dnsPromises.resolve(host, 'TXT').catch(() => []); + + const [cnameRecords, txtRecords, vercelTxtRecords] = await Promise.all([ + getCnameRecords(domain), + getTxtRecords(rootDomain), + getTxtRecords(`_vercel.${rootDomain}`), + ]); const isVercelDomain = await db.trust.findUnique({ where: { organizationId: activeOrgId, @@ -100,61 +86,38 @@ export const checkDnsRecordAction = authActionClient let isCnameVerified = false; - if (cnameRecords) { + if (cnameRecords.length > 0) { // First try strict pattern - isCnameVerified = cnameRecords.some((record: { address: string }) => - VERCEL_DNS_CNAME_PATTERN.test(record.address), + isCnameVerified = cnameRecords.some((address) => + VERCEL_DNS_CNAME_PATTERN.test(address), ); // If strict fails, try fallback pattern (catches new Vercel patterns we haven't seen) if (!isCnameVerified) { - const fallbackMatch = cnameRecords.find((record: { address: string }) => - VERCEL_DNS_FALLBACK_PATTERN.test(record.address), + const fallbackMatch = cnameRecords.find((address) => + VERCEL_DNS_FALLBACK_PATTERN.test(address), ); if (fallbackMatch) { console.warn( `[DNS Check] CNAME matched fallback pattern but not strict pattern. ` + - `Address: ${fallbackMatch.address}. Consider updating VERCEL_DNS_CNAME_PATTERN.`, + `Address: ${fallbackMatch}. Consider updating VERCEL_DNS_CNAME_PATTERN.`, ); isCnameVerified = true; } } } - let isTxtVerified = false; - let isVercelTxtVerified = false; + // Node's resolve(host, 'TXT') returns string[][] - each inner array is one TXT record + const txtRecordMatches = (records: string[][], expected: string | null) => + expected != null && + records.some((segments) => segments.some((s) => s === expected)); - if (txtRecords) { - // Check for our custom TXT record - isTxtVerified = txtRecords.some((record: any) => { - if (typeof record === 'string') { - return record === expectedTxtValue; - } - if (record && typeof record.value === 'string') { - return record.value === expectedTxtValue; - } - if (record && Array.isArray(record.txt) && record.txt.length > 0) { - return record.txt.some((txt: string) => txt === expectedTxtValue); - } - return false; - }); - } - - if (vercelTxtRecords) { - isVercelTxtVerified = vercelTxtRecords.some((record: any) => { - if (typeof record === 'string') { - return record === expectedVercelTxtValue; - } - if (record && typeof record.value === 'string') { - return record.value === expectedVercelTxtValue; - } - if (record && Array.isArray(record.txt) && record.txt.length > 0) { - return record.txt.some((txt: string) => txt === expectedVercelTxtValue); - } - return false; - }); - } + const isTxtVerified = txtRecordMatches(txtRecords, expectedTxtValue); + const isVercelTxtVerified = txtRecordMatches( + vercelTxtRecords, + expectedVercelTxtValue ?? null, + ); const isVerified = isCnameVerified && isTxtVerified && isVercelTxtVerified; From 283932d824767e99887cee96b544a435f9821800 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 12 Feb 2026 07:21:56 -0500 Subject: [PATCH 5/7] [dev] [carhartlewis] lewis/comp-org-chart (#2124) * feat(org-chart): add organization chart functionality and job title field * feat(org-chart): implement upsert functionality and enhance org chart DTO * feat(org-chart): enhance file upload validation and add org chart member type * feat(org-chart): update org chart DTO structure and enhance upload handling * refactor(org-chart): clean up imports and simplify code structure in OrgChartEditor * feat(employee): add job title field and implement member reactivation functionality * feat(people): add job title field to PeopleResponseDto and update queries * refactor(people): simplify tab labels in PeoplePageTabs component --------- Co-authored-by: Lewis Carhart Co-authored-by: Mariano Fuentes --- apps/api/src/app.module.ts | 2 + .../src/org-chart/dto/upload-org-chart.dto.ts | 22 ++ .../src/org-chart/dto/upsert-org-chart.dto.ts | 17 + .../api/src/org-chart/org-chart.controller.ts | 78 ++++ apps/api/src/org-chart/org-chart.module.ts | 12 + apps/api/src/org-chart/org-chart.service.ts | 280 ++++++++++++++ apps/api/src/people/dto/create-people.dto.ts | 9 + .../src/people/dto/people-responses.dto.ts | 7 + apps/api/src/people/utils/member-queries.ts | 3 + apps/app/package.json | 1 + .../[employeeId]/actions/update-employee.ts | 13 +- .../components/EmployeeDetails.tsx | 273 ++++++++++---- .../components/Fields/Department.tsx | 57 --- .../[employeeId]/components/Fields/Email.tsx | 36 -- .../components/Fields/JoinDate.tsx | 76 ---- .../[employeeId]/components/Fields/Name.tsx | 30 -- .../[employeeId]/components/Fields/Status.tsx | 67 ---- .../people/all/actions/reactivateMember.ts | 102 ++++++ .../people/all/actions/removeMember.ts | 4 + .../people/all/components/MemberRow.tsx | 106 +++--- .../people/all/components/TeamMembers.tsx | 2 + .../all/components/TeamMembersClient.tsx | 16 + .../people/components/PeoplePageTabs.tsx | 10 +- .../org-chart/components/OrgChartContent.tsx | 68 ++++ .../org-chart/components/OrgChartEditor.tsx | 341 ++++++++++++++++++ .../components/OrgChartEmptyState.tsx | 102 ++++++ .../components/OrgChartImageView.tsx | 85 +++++ .../org-chart/components/OrgChartNode.tsx | 132 +++++++ .../org-chart/components/PeopleSidebar.tsx | 167 +++++++++ .../components/UploadOrgChartDialog.tsx | 152 ++++++++ .../(app)/[orgId]/people/org-chart/types.ts | 9 + .../app/src/app/(app)/[orgId]/people/page.tsx | 80 +++- .../tables/people/employee-status.tsx | 7 +- apps/app/src/lib/org-chart.ts | 48 +++ apps/app/src/test-utils/mocks/auth.ts | 1 + bun.lock | 1 + .../migration.sql | 23 ++ .../migration.sql | 2 + packages/db/prisma/schema/auth.prisma | 1 + packages/db/prisma/schema/org-chart.prisma | 14 + packages/db/prisma/schema/organization.prisma | 3 + packages/docs/openapi.json | 165 +++++++++ 42 files changed, 2229 insertions(+), 395 deletions(-) create mode 100644 apps/api/src/org-chart/dto/upload-org-chart.dto.ts create mode 100644 apps/api/src/org-chart/dto/upsert-org-chart.dto.ts create mode 100644 apps/api/src/org-chart/org-chart.controller.ts create mode 100644 apps/api/src/org-chart/org-chart.module.ts create mode 100644 apps/api/src/org-chart/org-chart.service.ts delete mode 100644 apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Department.tsx delete mode 100644 apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Email.tsx delete mode 100644 apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/JoinDate.tsx delete mode 100644 apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Name.tsx delete mode 100644 apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Status.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/people/all/actions/reactivateMember.ts create mode 100644 apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartContent.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartEditor.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartEmptyState.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartImageView.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/people/org-chart/components/OrgChartNode.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/people/org-chart/components/PeopleSidebar.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/people/org-chart/components/UploadOrgChartDialog.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/people/org-chart/types.ts create mode 100644 apps/app/src/lib/org-chart.ts create mode 100644 packages/db/prisma/migrations/20260211165134_add_organization_chart/migration.sql create mode 100644 packages/db/prisma/migrations/20260211170222_add_member_job_title/migration.sql create mode 100644 packages/db/prisma/schema/org-chart.prisma diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 31be83c2c..2645cb341 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -33,6 +33,7 @@ import { CloudSecurityModule } from './cloud-security/cloud-security.module'; import { BrowserbaseModule } from './browserbase/browserbase.module'; import { TaskManagementModule } from './task-management/task-management.module'; import { AssistantChatModule } from './assistant-chat/assistant-chat.module'; +import { OrgChartModule } from './org-chart/org-chart.module'; import { TrainingModule } from './training/training.module'; @Module({ @@ -80,6 +81,7 @@ import { TrainingModule } from './training/training.module'; TaskManagementModule, AssistantChatModule, TrainingModule, + OrgChartModule, ], controllers: [AppController], providers: [ diff --git a/apps/api/src/org-chart/dto/upload-org-chart.dto.ts b/apps/api/src/org-chart/dto/upload-org-chart.dto.ts new file mode 100644 index 000000000..857109bec --- /dev/null +++ b/apps/api/src/org-chart/dto/upload-org-chart.dto.ts @@ -0,0 +1,22 @@ +import { IsNotEmpty, IsString, Matches } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class UploadOrgChartDto { + @ApiProperty({ description: 'Original file name' }) + @IsString() + @IsNotEmpty() + fileName: string; + + @ApiProperty({ description: 'MIME type of the file (e.g. image/png)' }) + @IsString() + @IsNotEmpty() + @Matches(/^[a-zA-Z0-9\-]+\/[a-zA-Z0-9\-\+\.]+$/, { + message: 'Invalid MIME type format', + }) + fileType: string; + + @ApiProperty({ description: 'Base64-encoded file data' }) + @IsString() + @IsNotEmpty() + fileData: string; +} diff --git a/apps/api/src/org-chart/dto/upsert-org-chart.dto.ts b/apps/api/src/org-chart/dto/upsert-org-chart.dto.ts new file mode 100644 index 000000000..b88207336 --- /dev/null +++ b/apps/api/src/org-chart/dto/upsert-org-chart.dto.ts @@ -0,0 +1,17 @@ +import { IsArray, IsOptional, IsString } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class UpsertOrgChartDto { + @ApiPropertyOptional({ description: 'Name of the org chart' }) + @IsOptional() + @IsString() + name?: string; + + @ApiProperty({ description: 'React Flow nodes array', type: [Object] }) + @IsArray() + nodes: any[]; + + @ApiProperty({ description: 'React Flow edges array', type: [Object] }) + @IsArray() + edges: any[]; +} diff --git a/apps/api/src/org-chart/org-chart.controller.ts b/apps/api/src/org-chart/org-chart.controller.ts new file mode 100644 index 000000000..c9cea1ddf --- /dev/null +++ b/apps/api/src/org-chart/org-chart.controller.ts @@ -0,0 +1,78 @@ +import { + Controller, + Get, + Put, + Post, + Delete, + Body, + UseGuards, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { + ApiHeader, + ApiOperation, + ApiResponse, + ApiSecurity, + ApiTags, +} from '@nestjs/swagger'; +import { OrganizationId } from '../auth/auth-context.decorator'; +import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; +import { OrgChartService } from './org-chart.service'; +import { UpsertOrgChartDto } from './dto/upsert-org-chart.dto'; +import { UploadOrgChartDto } from './dto/upload-org-chart.dto'; + +@ApiTags('Org Chart') +@Controller({ path: 'org-chart', version: '1' }) +@UseGuards(HybridAuthGuard) +@ApiSecurity('apikey') +@ApiHeader({ + name: 'X-Organization-Id', + description: + 'Organization ID (required for session auth, optional for API key auth)', + required: false, +}) +export class OrgChartController { + constructor(private readonly orgChartService: OrgChartService) {} + + @Get() + @ApiOperation({ summary: 'Get the organization chart' }) + @ApiResponse({ status: 200, description: 'The organization chart' }) + async getOrgChart(@OrganizationId() organizationId: string) { + return await this.orgChartService.findByOrganization(organizationId); + } + + @Put() + @ApiOperation({ summary: 'Create or update an interactive organization chart' }) + @ApiResponse({ status: 200, description: 'The saved organization chart' }) + @UsePipes(new ValidationPipe({ whitelist: false, transform: false })) + async upsertOrgChart( + @OrganizationId() organizationId: string, + @Body() body: Record, + ) { + const dto: UpsertOrgChartDto = { + name: typeof body?.name === 'string' ? body.name : undefined, + nodes: Array.isArray(body?.nodes) ? body.nodes : [], + edges: Array.isArray(body?.edges) ? body.edges : [], + }; + return await this.orgChartService.upsertInteractive(organizationId, dto); + } + + @Post('upload') + @ApiOperation({ summary: 'Upload an image as the organization chart' }) + @ApiResponse({ status: 201, description: 'The uploaded organization chart' }) + @UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })) + async uploadOrgChart( + @OrganizationId() organizationId: string, + @Body() dto: UploadOrgChartDto, + ) { + return await this.orgChartService.uploadImage(organizationId, dto); + } + + @Delete() + @ApiOperation({ summary: 'Delete the organization chart' }) + @ApiResponse({ status: 200, description: 'Deletion confirmation' }) + async deleteOrgChart(@OrganizationId() organizationId: string) { + return await this.orgChartService.delete(organizationId); + } +} diff --git a/apps/api/src/org-chart/org-chart.module.ts b/apps/api/src/org-chart/org-chart.module.ts new file mode 100644 index 000000000..0c6a7053a --- /dev/null +++ b/apps/api/src/org-chart/org-chart.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { OrgChartController } from './org-chart.controller'; +import { OrgChartService } from './org-chart.service'; + +@Module({ + imports: [AuthModule], + controllers: [OrgChartController], + providers: [OrgChartService], + exports: [OrgChartService], +}) +export class OrgChartModule {} diff --git a/apps/api/src/org-chart/org-chart.service.ts b/apps/api/src/org-chart/org-chart.service.ts new file mode 100644 index 000000000..b70ac4a41 --- /dev/null +++ b/apps/api/src/org-chart/org-chart.service.ts @@ -0,0 +1,280 @@ +import { + BadRequestException, + Injectable, + InternalServerErrorException, + Logger, +} from '@nestjs/common'; +import { + DeleteObjectCommand, + GetObjectCommand, + PutObjectCommand, + S3Client, +} from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { db } from '@trycompai/db'; +import { s3Client, BUCKET_NAME } from '@/app/s3'; +import type { UpsertOrgChartDto } from './dto/upsert-org-chart.dto'; +import type { UploadOrgChartDto } from './dto/upload-org-chart.dto'; + +@Injectable() +export class OrgChartService { + private readonly logger = new Logger(OrgChartService.name); + private s3Client: S3Client | null; + private bucketName: string | undefined; + private readonly SIGNED_URL_EXPIRY = 900; // 15 minutes + private readonly MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024; // 100MB + private readonly ALLOWED_UPLOAD_MIME_TYPES = [ + 'image/png', + 'image/jpeg', + 'image/gif', + 'image/webp', + 'image/svg+xml', + 'image/bmp', + 'image/tiff', + 'application/pdf', + ]; + + constructor() { + this.s3Client = s3Client ?? null; + this.bucketName = BUCKET_NAME; + } + + async findByOrganization(organizationId: string) { + try { + const chart = await db.organizationChart.findUnique({ + where: { organizationId }, + }); + + if (!chart) { + return null; + } + + // If there's an uploaded image, generate a presigned URL + let signedImageUrl: string | null = null; + if (chart.type === 'uploaded' && chart.uploadedImageUrl) { + signedImageUrl = await this.getSignedUrl(chart.uploadedImageUrl); + } + + return { + ...chart, + signedImageUrl, + }; + } catch (error) { + this.logger.error( + `Failed to fetch org chart for organization ${organizationId}:`, + error, + ); + throw new InternalServerErrorException('Failed to fetch org chart'); + } + } + + async upsertInteractive( + organizationId: string, + data: UpsertOrgChartDto, + ) { + try { + this.logger.log( + `[OrgChart API] Saving for org ${organizationId}: nodes=${data.nodes?.length ?? 'null'}, edges=${data.edges?.length ?? 'null'}`, + ); + + // Check for an existing uploaded image before the upsert so we can + // clean it up from S3 *after* the DB write succeeds. + const existing = await db.organizationChart.findUnique({ + where: { organizationId }, + }); + const previousImageKey = existing?.uploadedImageUrl ?? null; + + const chart = await db.organizationChart.upsert({ + where: { organizationId }, + create: { + organizationId, + type: 'interactive', + nodes: data.nodes as any, + edges: data.edges as any, + ...(data.name && { name: data.name }), + }, + update: { + type: 'interactive', + nodes: data.nodes as any, + edges: data.edges as any, + uploadedImageUrl: null, + ...(data.name && { name: data.name }), + }, + }); + + // Delete the old S3 object only after the DB update succeeded, + // so a DB failure doesn't orphan the image permanently. + if (previousImageKey) { + await this.deleteS3Object(previousImageKey); + } + + this.logger.log( + `Upserted interactive org chart for organization ${organizationId}`, + ); + return chart; + } catch (error) { + this.logger.error( + `Failed to upsert org chart for organization ${organizationId}:`, + error, + ); + throw new InternalServerErrorException('Failed to save org chart'); + } + } + + async uploadImage( + organizationId: string, + data: UploadOrgChartDto, + ) { + if (!this.s3Client || !this.bucketName) { + throw new InternalServerErrorException( + 'File upload service is not available', + ); + } + + try { + // Validate MIME type is an allowed upload type (images + PDF) + const normalizedType = data.fileType.toLowerCase(); + if (!this.ALLOWED_UPLOAD_MIME_TYPES.includes(normalizedType)) { + throw new BadRequestException( + `File type '${data.fileType}' is not allowed. Supported formats: PNG, JPG, GIF, WebP, SVG, BMP, TIFF, PDF.`, + ); + } + + const fileBuffer = Buffer.from(data.fileData, 'base64'); + + if (fileBuffer.length > this.MAX_FILE_SIZE_BYTES) { + throw new BadRequestException( + 'File exceeds the 100MB size limit', + ); + } + + // Delete old image if it exists + const existing = await db.organizationChart.findUnique({ + where: { organizationId }, + }); + if (existing?.uploadedImageUrl) { + await this.deleteS3Object(existing.uploadedImageUrl); + } + + const timestamp = Date.now(); + const sanitizedFileName = data.fileName.replace(/[^a-zA-Z0-9.-]/g, '_'); + const s3Key = `${organizationId}/org-chart/${timestamp}-${sanitizedFileName}`; + + const putCommand = new PutObjectCommand({ + Bucket: this.bucketName, + Key: s3Key, + Body: fileBuffer, + ContentType: data.fileType, + }); + await this.s3Client.send(putCommand); + + const chart = await db.organizationChart.upsert({ + where: { organizationId }, + create: { + organizationId, + type: 'uploaded', + uploadedImageUrl: s3Key, + }, + update: { + type: 'uploaded', + uploadedImageUrl: s3Key, + nodes: [], + edges: [], + }, + }); + + const signedImageUrl = await this.getSignedUrl(s3Key); + + this.logger.log( + `Uploaded org chart image for organization ${organizationId}`, + ); + + return { + ...chart, + signedImageUrl, + }; + } catch (error) { + if ( + error instanceof BadRequestException || + error instanceof InternalServerErrorException + ) { + throw error; + } + this.logger.error( + `Failed to upload org chart image for organization ${organizationId}:`, + error, + ); + throw new InternalServerErrorException( + 'Failed to upload org chart image', + ); + } + } + + async delete(organizationId: string) { + try { + const existing = await db.organizationChart.findUnique({ + where: { organizationId }, + }); + + if (!existing) { + return { message: 'No org chart found' }; + } + + // Delete S3 image if applicable + if (existing.uploadedImageUrl) { + await this.deleteS3Object(existing.uploadedImageUrl); + } + + await db.organizationChart.delete({ + where: { organizationId }, + }); + + this.logger.log( + `Deleted org chart for organization ${organizationId}`, + ); + + return { message: 'Org chart deleted successfully' }; + } catch (error) { + this.logger.error( + `Failed to delete org chart for organization ${organizationId}:`, + error, + ); + throw new InternalServerErrorException('Failed to delete org chart'); + } + } + + private async getSignedUrl(s3Key: string): Promise { + if (!this.s3Client || !this.bucketName) { + return null; + } + + try { + const getCommand = new GetObjectCommand({ + Bucket: this.bucketName, + Key: s3Key, + }); + return await getSignedUrl(this.s3Client, getCommand, { + expiresIn: this.SIGNED_URL_EXPIRY, + }); + } catch (error) { + this.logger.error(`Failed to generate signed URL for ${s3Key}:`, error); + return null; + } + } + + private async deleteS3Object(s3Key: string): Promise { + if (!this.s3Client || !this.bucketName) { + return; + } + + try { + const deleteCommand = new DeleteObjectCommand({ + Bucket: this.bucketName, + Key: s3Key, + }); + await this.s3Client.send(deleteCommand); + } catch (error) { + this.logger.error(`Failed to delete S3 object ${s3Key}:`, error); + } + } +} diff --git a/apps/api/src/people/dto/create-people.dto.ts b/apps/api/src/people/dto/create-people.dto.ts index 4c36b7a9a..7d540bd85 100644 --- a/apps/api/src/people/dto/create-people.dto.ts +++ b/apps/api/src/people/dto/create-people.dto.ts @@ -50,4 +50,13 @@ export class CreatePeopleDto { @IsOptional() @IsNumber() fleetDmLabelId?: number; + + @ApiProperty({ + description: 'Job title for the member', + example: 'Software Engineer', + required: false, + }) + @IsOptional() + @IsString() + jobTitle?: string; } diff --git a/apps/api/src/people/dto/people-responses.dto.ts b/apps/api/src/people/dto/people-responses.dto.ts index be8723a26..28d7cabdf 100644 --- a/apps/api/src/people/dto/people-responses.dto.ts +++ b/apps/api/src/people/dto/people-responses.dto.ts @@ -91,6 +91,13 @@ export class PeopleResponseDto { }) department: Departments; + @ApiProperty({ + description: 'Job title for the member', + example: 'Software Engineer', + nullable: true, + }) + jobTitle: string | null; + @ApiProperty({ description: 'Whether member is active', example: true, diff --git a/apps/api/src/people/utils/member-queries.ts b/apps/api/src/people/utils/member-queries.ts index 3e6849247..a5b7a7df6 100644 --- a/apps/api/src/people/utils/member-queries.ts +++ b/apps/api/src/people/utils/member-queries.ts @@ -17,6 +17,7 @@ export class MemberQueries { role: true, createdAt: true, department: true, + jobTitle: true, isActive: true, fleetDmLabelId: true, user: { @@ -77,6 +78,7 @@ export class MemberQueries { department: createData.department || 'none', isActive: createData.isActive ?? true, fleetDmLabelId: createData.fleetDmLabelId || null, + jobTitle: createData.jobTitle || null, }, select: this.MEMBER_SELECT, }); @@ -170,6 +172,7 @@ export class MemberQueries { department: member.department || 'none', isActive: member.isActive ?? true, fleetDmLabelId: member.fleetDmLabelId || null, + jobTitle: member.jobTitle || null, })); // Perform bulk insert diff --git a/apps/app/package.json b/apps/app/package.json index 7855ec0a3..19c7b1f2b 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -66,6 +66,7 @@ "@vercel/analytics": "^1.5.0", "@vercel/sandbox": "^0.0.21", "@vercel/sdk": "^1.7.1", + "@xyflow/react": "^12.10.0", "ai": "^5.0.108", "ai-elements": "^1.6.1", "axios": "^1.9.0", diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/update-employee.ts b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/update-employee.ts index d902f73ac..43170355d 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/update-employee.ts +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/update-employee.ts @@ -1,6 +1,7 @@ 'use server'; import { authActionClient } from '@/actions/safe-action'; +import { removeMemberFromOrgChart } from '@/lib/org-chart'; import type { Departments } from '@db'; import { db, Prisma } from '@db'; import { revalidatePath } from 'next/cache'; @@ -14,6 +15,7 @@ const schema = z.object({ department: z.string().optional(), isActive: z.boolean().optional(), createdAt: z.date().optional(), + jobTitle: z.string().optional(), }); export const updateEmployee = authActionClient @@ -26,7 +28,7 @@ export const updateEmployee = authActionClient }, }) .action(async ({ parsedInput, ctx }) => { - const { employeeId, name, email, department, isActive, createdAt } = parsedInput; + const { employeeId, name, email, department, isActive, createdAt, jobTitle } = parsedInput; const organizationId = ctx.session.activeOrganizationId; if (!organizationId) { @@ -81,6 +83,7 @@ export const updateEmployee = authActionClient department?: Departments; isActive?: boolean; createdAt?: Date; + jobTitle?: string; } = {}; const userUpdateData: { name?: string; email?: string } = {}; @@ -93,6 +96,9 @@ export const updateEmployee = authActionClient if (createdAt !== undefined && createdAt.toISOString() !== member.createdAt.toISOString()) { memberUpdateData.createdAt = createdAt; } + if (jobTitle !== undefined && jobTitle !== member.jobTitle) { + memberUpdateData.jobTitle = jobTitle; + } if (name !== undefined && name !== member.user.name) { userUpdateData.name = name; } @@ -135,6 +141,11 @@ export const updateEmployee = authActionClient } }); + // If the member was just deactivated, remove them from the org chart + if (memberUpdateData.isActive === false) { + await removeMemberFromOrgChart(organizationId, employeeId); + } + revalidatePath(`/${organizationId}/people/${employeeId}`); revalidatePath(`/${organizationId}/people`); diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeDetails.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeDetails.tsx index d9a16b72e..2bfb92a3f 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeDetails.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeDetails.tsx @@ -1,32 +1,43 @@ 'use client'; -import { Button } from '@comp/ui/button'; -import { Form } from '@comp/ui/form'; +import { Popover, PopoverContent, PopoverTrigger } from '@comp/ui/popover'; import type { Departments, Member, User } from '@db'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { Section, Stack } from '@trycompai/design-system'; -import { Save } from 'lucide-react'; +import { + Button, + Calendar, + Grid, + HStack, + Input, + Label, + Section, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Stack, +} from '@trycompai/design-system'; +import { ChevronDown } from '@trycompai/design-system/icons'; +import { format } from 'date-fns'; import { useAction } from 'next-safe-action/hooks'; -import { useForm } from 'react-hook-form'; +import { useMemo, useState } from 'react'; import { toast } from 'sonner'; -import { z } from 'zod'; import { updateEmployee } from '../actions/update-employee'; -import { Department } from './Fields/Department'; -import { Email } from './Fields/Email'; -import { JoinDate } from './Fields/JoinDate'; -import { Name } from './Fields/Name'; -import { Status } from './Fields/Status'; - -// Define form schema with Zod -const employeeFormSchema = z.object({ - name: z.string().min(1, 'Name is required'), - email: z.string().email('Invalid email address'), - department: z.enum(['admin', 'gov', 'hr', 'it', 'itsm', 'qms', 'none'] as const), - status: z.enum(['active', 'inactive'] as const), - createdAt: z.date(), -}); - -export type EmployeeFormValues = z.infer; + +const DEPARTMENTS: { value: string; label: string }[] = [ + { value: 'admin', label: 'Admin' }, + { value: 'gov', label: 'Governance' }, + { value: 'hr', label: 'HR' }, + { value: 'it', label: 'IT' }, + { value: 'itsm', label: 'IT Service Management' }, + { value: 'qms', label: 'Quality Management' }, + { value: 'none', label: 'None' }, +]; + +const STATUS_OPTIONS = [ + { value: 'active', label: 'Active' }, + { value: 'inactive', label: 'Inactive' }, +]; export const EmployeeDetails = ({ employee, @@ -37,17 +48,12 @@ export const EmployeeDetails = ({ }; canEdit: boolean; }) => { - const form = useForm({ - resolver: zodResolver(employeeFormSchema), - defaultValues: { - name: employee.user.name ?? '', - email: employee.user.email ?? '', - department: employee.department as Departments, - status: employee.isActive ? 'active' : 'inactive', - createdAt: new Date(employee.createdAt), - }, - mode: 'onChange', - }); + const [name, setName] = useState(employee.user.name ?? ''); + const [jobTitle, setJobTitle] = useState(employee.jobTitle ?? ''); + const [department, setDepartment] = useState(employee.department ?? 'none'); + const [status, setStatus] = useState(employee.isActive ? 'active' : 'inactive'); + const [joinDate, setJoinDate] = useState(new Date(employee.createdAt)); + const [datePickerOpen, setDatePickerOpen] = useState(false); const { execute, status: actionStatus } = useAction(updateEmployee, { onSuccess: (res) => { @@ -62,75 +68,188 @@ export const EmployeeDetails = ({ }, }); - const onSubmit = async (values: EmployeeFormValues) => { - // Prepare update data + const hasChanges = useMemo(() => { + const nameChanged = name !== (employee.user.name ?? ''); + const jobTitleChanged = jobTitle !== (employee.jobTitle ?? ''); + const departmentChanged = department !== (employee.department ?? 'none'); + const statusChanged = status !== (employee.isActive ? 'active' : 'inactive'); + const dateChanged = joinDate.toISOString() !== new Date(employee.createdAt).toISOString(); + + return nameChanged || jobTitleChanged || departmentChanged || statusChanged || dateChanged; + }, [name, jobTitle, department, status, joinDate, employee]); + + const isLoading = actionStatus === 'executing'; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (!name.trim()) { + toast.error('Name is required'); + return; + } + const updateData: { employeeId: string; name?: string; - email?: string; department?: string; isActive?: boolean; createdAt?: Date; + jobTitle?: string; } = { employeeId: employee.id }; - // Only include changed fields - if (values.name !== employee.user.name) { - updateData.name = values.name; + if (name !== (employee.user.name ?? '')) { + updateData.name = name; } - if (values.email !== employee.user.email) { - updateData.email = values.email; + if (jobTitle !== (employee.jobTitle ?? '')) { + updateData.jobTitle = jobTitle; } - if (values.department !== employee.department) { - updateData.department = values.department; + if (department !== employee.department) { + updateData.department = department; } - if (values.createdAt && values.createdAt.toISOString() !== employee.createdAt.toISOString()) { - updateData.createdAt = values.createdAt; + if (joinDate.toISOString() !== new Date(employee.createdAt).toISOString()) { + updateData.createdAt = joinDate; } - const isActive = values.status === 'active'; + const isActive = status === 'active'; if (isActive !== employee.isActive) { updateData.isActive = isActive; } - // Execute the update only if there are changes if (Object.keys(updateData).length > 1) { - await execute(updateData); + execute(updateData); } else { - // No changes were made toast.info('No changes to save'); } }; return (
-
- - -
- - - - - -
-
- + + + date && setJoinDate(date)} + captionLayout="dropdown" + disabled={(date) => date > new Date()} + /> + + + + + + + + + +
); }; diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Department.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Department.tsx deleted file mode 100644 index c08004b8e..000000000 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Department.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@comp/ui/form'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@comp/ui/select'; -import type { Departments } from '@db'; -import type { Control } from 'react-hook-form'; -import type { EmployeeFormValues } from '../EmployeeDetails'; - -const DEPARTMENTS: { value: Departments; label: string }[] = [ - { value: 'admin', label: 'Admin' }, - { value: 'gov', label: 'Governance' }, - { value: 'hr', label: 'HR' }, - { value: 'it', label: 'IT' }, - { value: 'itsm', label: 'IT Service Management' }, - { value: 'qms', label: 'Quality Management' }, - { value: 'none', label: 'None' }, -]; - -export const Department = ({ - control, - disabled, -}: { - control: Control; - disabled: boolean; -}) => { - return ( - ( - - - Department - - - - - )} - /> - ); -}; diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Email.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Email.tsx deleted file mode 100644 index af31cdd29..000000000 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Email.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@comp/ui/form'; -import { Input } from '@comp/ui/input'; -import type { Control } from 'react-hook-form'; -import type { EmployeeFormValues } from '../EmployeeDetails'; - -export const Email = ({ - control, - disabled, -}: { - control: Control; - disabled: boolean; -}) => { - return ( - ( - - - EMAIL - - - - - - - )} - /> - ); -}; diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/JoinDate.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/JoinDate.tsx deleted file mode 100644 index da6f2c223..000000000 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/JoinDate.tsx +++ /dev/null @@ -1,76 +0,0 @@ -'use client'; - -import { Button } from '@comp/ui/button'; -import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@comp/ui/form'; -import { Popover, PopoverContent, PopoverTrigger } from '@comp/ui/popover'; -import { Calendar } from '@trycompai/design-system'; -import { format } from 'date-fns'; -import { ChevronDown } from 'lucide-react'; -import { useState } from 'react'; -import type { Control } from 'react-hook-form'; -import type { EmployeeFormValues } from '../EmployeeDetails'; - -export const JoinDate = ({ - control, - disabled, -}: { - control: Control; - disabled: boolean; -}) => { - const [open, setOpen] = useState(false); - - return ( - { - return ( - - - - - Join Date - - - - - - date && field.onChange(date)} - captionLayout="dropdown" - disabled={(date) => date > new Date()} - /> -
- -
-
-
-
- -
- ); - }} - /> - ); -}; diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Name.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Name.tsx deleted file mode 100644 index 38dc6c952..000000000 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Name.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@comp/ui/form'; -import { Input } from '@comp/ui/input'; -import type { Control } from 'react-hook-form'; -import type { EmployeeFormValues } from '../EmployeeDetails'; - -export const Name = ({ - control, - disabled, -}: { - control: Control; - disabled: boolean; -}) => { - return ( - ( - - - NAME - - - - - - - )} - /> - ); -}; diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Status.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Status.tsx deleted file mode 100644 index 7368291df..000000000 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Status.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import type { EmployeeStatusType } from '@/components/tables/people/employee-status'; -import { cn } from '@comp/ui/cn'; -import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@comp/ui/form'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@comp/ui/select'; -import type { Control } from 'react-hook-form'; -import type { EmployeeFormValues } from '../EmployeeDetails'; - -const STATUS_OPTIONS: { value: EmployeeStatusType; label: string }[] = [ - { value: 'active', label: 'Active' }, - { value: 'inactive', label: 'Inactive' }, -]; - -// Status color hex values for charts -export const EMPLOYEE_STATUS_HEX_COLORS: Record = { - inactive: 'var(--color-destructive)', - active: 'var(--color-primary)', -}; - -export const Status = ({ - control, - disabled, -}: { - control: Control; - disabled: boolean; -}) => { - return ( - ( - - - Status - - - - - )} - /> - ); -}; diff --git a/apps/app/src/app/(app)/[orgId]/people/all/actions/reactivateMember.ts b/apps/app/src/app/(app)/[orgId]/people/all/actions/reactivateMember.ts new file mode 100644 index 000000000..433fea0cb --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/all/actions/reactivateMember.ts @@ -0,0 +1,102 @@ +'use server'; + +import { db } from '@db'; +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; +import { authActionClient } from '@/actions/safe-action'; +import type { ActionResponse } from '@/actions/types'; + +const reactivateMemberSchema = z.object({ + memberId: z.string(), +}); + +export const reactivateMember = authActionClient + .metadata({ + name: 'reactivate-member', + track: { + event: 'reactivate_member', + channel: 'organization', + }, + }) + .inputSchema(reactivateMemberSchema) + .action(async ({ parsedInput, ctx }): Promise> => { + if (!ctx.session.activeOrganizationId) { + return { + success: false, + error: 'User does not have an organization', + }; + } + + const { memberId } = parsedInput; + + try { + // Check if user has admin permissions + const currentUserMember = await db.member.findFirst({ + where: { + organizationId: ctx.session.activeOrganizationId, + userId: ctx.user.id, + deactivated: false, + }, + }); + + if ( + !currentUserMember || + (!currentUserMember.role.includes('admin') && !currentUserMember.role.includes('owner')) + ) { + return { + success: false, + error: "You don't have permission to reactivate members", + }; + } + + // Check if the target member exists and is deactivated + const targetMember = await db.member.findFirst({ + where: { + id: memberId, + organizationId: ctx.session.activeOrganizationId, + }, + include: { + user: true, + }, + }); + + if (!targetMember) { + return { + success: false, + error: 'Member not found in this organization', + }; + } + + if (!targetMember.deactivated && targetMember.isActive) { + return { + success: false, + error: 'Member is already active', + }; + } + + // Reactivate the member + await db.member.update({ + where: { + id: memberId, + }, + data: { + deactivated: false, + isActive: true, + }, + }); + + revalidatePath(`/${ctx.session.activeOrganizationId}/people`); + revalidatePath(`/${ctx.session.activeOrganizationId}/people/${memberId}`); + + return { + success: true, + data: { reactivated: true }, + }; + } catch (error) { + console.error('Error reactivating member:', error); + return { + success: false, + error: 'Failed to reactivate member', + }; + } + }); diff --git a/apps/app/src/app/(app)/[orgId]/people/all/actions/removeMember.ts b/apps/app/src/app/(app)/[orgId]/people/all/actions/removeMember.ts index 734f1ca62..62ed85869 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/actions/removeMember.ts +++ b/apps/app/src/app/(app)/[orgId]/people/all/actions/removeMember.ts @@ -12,6 +12,7 @@ import { type UnassignedItem, } from '@comp/email'; import { getFleetInstance } from '@/lib/fleet'; +import { removeMemberFromOrgChart } from '@/lib/org-chart'; const removeMemberSchema = z.object({ memberId: z.string(), @@ -236,6 +237,9 @@ export const removeMember = authActionClient }), ]); + // Remove the member from the org chart (if present) + await removeMemberFromOrgChart(ctx.session.activeOrganizationId, memberId); + // Mark the member as deactivated instead of deleting await db.member.update({ where: { diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx index 3c700371b..6f94b1b73 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx @@ -16,7 +16,7 @@ import { TableRow, Text, } from '@trycompai/design-system'; -import { Edit, OverflowMenuVertical, TrashCan } from '@trycompai/design-system/icons'; +import { Checkmark, Edit, OverflowMenuVertical, TrashCan } from '@trycompai/design-system/icons'; import { Button } from '@comp/ui/button'; import { Dialog, @@ -45,6 +45,7 @@ interface MemberRowProps { onRemove: (memberId: string) => void; onRemoveDevice: (memberId: string) => void; onUpdateRole: (memberId: string, roles: Role[]) => void; + onReactivate: (memberId: string) => void; canEdit: boolean; isCurrentUserOwner: boolean; } @@ -93,6 +94,7 @@ export function MemberRow({ onRemove, onRemoveDevice, onUpdateRole, + onReactivate, canEdit, isCurrentUserOwner, }: MemberRowProps) { @@ -106,6 +108,7 @@ export function MemberRow({ const [isUpdating, setIsUpdating] = useState(false); const [isRemoving, setIsRemoving] = useState(false); const [isRemovingDevice, setIsRemovingDevice] = useState(false); + const [isReactivating, setIsReactivating] = useState(false); const memberName = member.user.name || member.user.email || 'Member'; const memberEmail = member.user.email || ''; @@ -151,6 +154,16 @@ export function MemberRow({ } }; + const handleReactivateClick = async () => { + setDropdownOpen(false); + setIsReactivating(true); + try { + await onReactivate(memberId); + } finally { + setIsReactivating(false); + } + }; + const handleRemoveDeviceClick = async () => { try { setIsRemoveDeviceAlertOpen(false); @@ -211,48 +224,55 @@ export function MemberRow({ {/* ACTIONS */} - {!isDeactivated && ( -
- - - - - - {canEdit && ( - - - Edit Roles - - )} - {member.fleetDmLabelId && isCurrentUserOwner && ( - { - setDropdownOpen(false); - setIsRemoveDeviceAlertOpen(true); - }} - > - - Remove Device - - )} - {canRemove && ( - { - setDropdownOpen(false); - setIsRemoveAlertOpen(true); - }} - > - - Remove Member - - )} - - -
- )} +
+ + + + + + {isDeactivated && canEdit && ( + + + {isReactivating ? 'Reinstating...' : 'Reinstate Member'} + + )} + {!isDeactivated && canEdit && ( + + + Edit Roles + + )} + {!isDeactivated && member.fleetDmLabelId && isCurrentUserOwner && ( + { + setDropdownOpen(false); + setIsRemoveDeviceAlertOpen(true); + }} + > + + Remove Device + + )} + {!isDeactivated && canRemove && ( + { + setDropdownOpen(false); + setIsRemoveAlertOpen(true); + }} + > + + Remove Member + + )} + + +
diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembers.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembers.tsx index c04899ab9..dc45fc89c 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembers.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembers.tsx @@ -4,6 +4,7 @@ import { auth } from '@/utils/auth'; import type { Invitation, Member, User } from '@db'; import { db } from '@db'; import { headers } from 'next/headers'; +import { reactivateMember } from '../actions/reactivateMember'; import { removeMember } from '../actions/removeMember'; import { revokeInvitation } from '../actions/revokeInvitation'; import { getEmployeeSyncConnections } from '../data/queries'; @@ -80,6 +81,7 @@ export async function TeamMembers(props: TeamMembersProps) { data={data} organizationId={organizationId ?? ''} removeMemberAction={removeMember} + reactivateMemberAction={reactivateMember} revokeInvitationAction={revokeInvitation} canManageMembers={canManageMembers} canInviteUsers={canInviteUsers} diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx index 001fa1080..767e43913 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx @@ -37,6 +37,7 @@ import { PendingInvitationRow } from './PendingInvitationRow'; import type { MemberWithUser, TeamMembersData } from './TeamMembers'; // Import the server actions themselves to get their types +import type { reactivateMember } from '../actions/reactivateMember'; import type { removeMember } from '../actions/removeMember'; import type { revokeInvitation } from '../actions/revokeInvitation'; @@ -48,6 +49,7 @@ interface TeamMembersClientProps { data: TeamMembersData; organizationId: string; removeMemberAction: typeof removeMember; + reactivateMemberAction: typeof reactivateMember; revokeInvitationAction: typeof revokeInvitation; canManageMembers: boolean; canInviteUsers: boolean; @@ -72,6 +74,7 @@ export function TeamMembersClient({ data, organizationId, removeMemberAction, + reactivateMemberAction, revokeInvitationAction, canManageMembers, canInviteUsers, @@ -225,6 +228,18 @@ export function TeamMembersClient({ } }; + const handleReactivateMember = async (memberId: string) => { + const result = await reactivateMemberAction({ memberId }); + if (result?.data?.success) { + toast.success('Member has been reinstated'); + router.refresh(); + } else { + const errorMessage = result?.serverError || 'Failed to reinstate member'; + console.error('Reactivate Member Error:', errorMessage); + toast.error(errorMessage); + } + }; + const handleRemoveDevice = async (memberId: string) => { await unlinkDevice(memberId); toast.success('Device unlinked successfully'); @@ -503,6 +518,7 @@ export function TeamMembersClient({ onRemove={handleRemoveMember} onRemoveDevice={handleRemoveDevice} onUpdateRole={handleUpdateRole} + onReactivate={handleReactivateMember} canEdit={canManageMembers} isCurrentUserOwner={isCurrentUserOwner} /> diff --git a/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx b/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx index 8af9e307a..c64863c77 100644 --- a/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx @@ -18,6 +18,7 @@ interface PeoplePageTabsProps { peopleContent: ReactNode; employeeTasksContent: ReactNode | null; devicesContent: ReactNode; + orgChartContent: ReactNode; showEmployeeTasks: boolean; canInviteUsers: boolean; canManageMembers: boolean; @@ -28,6 +29,7 @@ export function PeoplePageTabs({ peopleContent, employeeTasksContent, devicesContent, + orgChartContent, showEmployeeTasks, canInviteUsers, canManageMembers, @@ -44,10 +46,9 @@ export function PeoplePageTabs({ tabs={ People - {showEmployeeTasks && ( - Employee Tasks - )} - Employee Devices + {showEmployeeTasks && Tasks} + Devices + Chart } actions={ @@ -67,6 +68,7 @@ export function PeoplePageTabs({ {employeeTasksContent} )} {devicesContent} + {orgChartContent} ; + } + + // Uploaded image mode + if (chartData.type === 'uploaded' && chartData.signedImageUrl) { + return ( + + ); + } + + // Uploaded chart but image could not be loaded (e.g. S3 unavailable) + if (chartData.type === 'uploaded') { + return ( +
+
+

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

+

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

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

+ No Org Chart Yet +

+

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

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

+ People +

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

+ No members found +

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

+ Upload Org Chart +

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

+ {selectedFile.name} +

+

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

+
+ ) : ( +
+

+ Drop an image here or click to browse +

+

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

+
+ )} +
+
+ )} +
+ +
+ + +
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/people/org-chart/types.ts b/apps/app/src/app/(app)/[orgId]/people/org-chart/types.ts new file mode 100644 index 000000000..aa3ed585f --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/org-chart/types.ts @@ -0,0 +1,9 @@ +export interface OrgChartMember { + id: string; + user: { + name: string; + email: string; + }; + role: string; + jobTitle?: string | null; +} diff --git a/apps/app/src/app/(app)/[orgId]/people/page.tsx b/apps/app/src/app/(app)/[orgId]/people/page.tsx index ec0f50eed..0af12e6b1 100644 --- a/apps/app/src/app/(app)/[orgId]/people/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/page.tsx @@ -1,4 +1,7 @@ import { auth } from '@/utils/auth'; +import { s3Client, BUCKET_NAME } from '@/app/s3'; +import { GetObjectCommand } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { db } from '@db'; import type { Metadata } from 'next'; import { headers } from 'next/headers'; @@ -10,6 +13,7 @@ import { DeviceComplianceChart } from './devices/components/DeviceComplianceChar import { EmployeeDevicesList } from './devices/components/EmployeeDevicesList'; import { getEmployeeDevices } from './devices/data'; import type { Host } from './devices/types'; +import { OrgChartContent } from './org-chart/components/OrgChartContent'; export default async function PeoplePage({ params }: { params: Promise<{ orgId: string }> }) { const { orgId } = await params; @@ -34,21 +38,85 @@ export default async function PeoplePage({ params }: { params: Promise<{ orgId: const canInviteUsers = canManageMembers || isAuditor; const isCurrentUserOwner = currentUserRoles.includes('owner'); - // Check if there are employees to show the Employee Tasks tab - const allMembers = await db.member.findMany({ + // Fetch members with user info (used for both employee check and org chart) + const membersWithUsers = await db.member.findMany({ where: { organizationId: orgId, deactivated: false, }, + include: { + user: { + select: { + name: true, + email: true, + }, + }, + }, }); - const employees = allMembers.filter((member) => { + // Check if there are employees to show the Employee Tasks tab + const employees = membersWithUsers.filter((member) => { const roles = member.role.includes(',') ? member.role.split(',') : [member.role]; return roles.includes('employee') || roles.includes('contractor'); }); const showEmployeeTasks = employees.length > 0; + // Fetch org chart data directly via Prisma + const orgChart = await db.organizationChart.findUnique({ + where: { organizationId: orgId }, + }); + + // Generate a signed URL for uploaded images + let orgChartData = null; + if (orgChart) { + let signedImageUrl: string | null = null; + if ( + orgChart.type === 'uploaded' && + orgChart.uploadedImageUrl && + s3Client && + BUCKET_NAME + ) { + try { + const cmd = new GetObjectCommand({ + Bucket: BUCKET_NAME, + Key: orgChart.uploadedImageUrl, + }); + signedImageUrl = await getSignedUrl(s3Client, cmd, { expiresIn: 900 }); + } catch { + // Signed URL generation failed; image won't render + } + } + + // Sanitize nodes/edges from JSON to ensure valid React Flow structures + const rawNodes = Array.isArray(orgChart.nodes) ? orgChart.nodes : []; + const rawEdges = Array.isArray(orgChart.edges) ? orgChart.edges : []; + + const sanitizedNodes = (rawNodes as Record[]) + .filter((n) => n && typeof n === 'object' && n.id) + .map((n) => ({ + ...n, + position: n.position && typeof (n.position as Record).x === 'number' + ? n.position + : { x: 0, y: 0 }, + })); + + const sanitizedEdges = (rawEdges as Record[]) + .filter((e) => e && typeof e === 'object' && e.source && e.target) + .map((e, i) => ({ + ...e, + id: e.id || `edge-${e.source}-${e.target}-${i}`, + })); + + orgChartData = { + ...orgChart, + nodes: sanitizedNodes, + edges: sanitizedEdges, + updatedAt: orgChart.updatedAt.toISOString(), + signedImageUrl, + }; + } + // Fetch devices data let devices: Host[] = []; try { @@ -76,6 +144,12 @@ export default async function PeoplePage({ params }: { params: Promise<{ orgId: } + orgChartContent={ + + } showEmployeeTasks={showEmployeeTasks} canInviteUsers={canInviteUsers} canManageMembers={canManageMembers} diff --git a/apps/app/src/components/tables/people/employee-status.tsx b/apps/app/src/components/tables/people/employee-status.tsx index fe2157e72..1fcf97bfb 100644 --- a/apps/app/src/components/tables/people/employee-status.tsx +++ b/apps/app/src/components/tables/people/employee-status.tsx @@ -1,10 +1,15 @@ -import { EMPLOYEE_STATUS_HEX_COLORS } from '@/app/(app)/[orgId]/people/[employeeId]/components/Fields/Status'; import { cn } from '@comp/ui/cn'; // Define employee status types export const EMPLOYEE_STATUS_TYPES = ['active', 'inactive'] as const; export type EmployeeStatusType = (typeof EMPLOYEE_STATUS_TYPES)[number]; +// Status color hex values for charts +export const EMPLOYEE_STATUS_HEX_COLORS: Record = { + inactive: 'var(--color-destructive)', + active: 'var(--color-primary)', +}; + /** * EmployeeStatus component that matches the styling of the Status component * but uses active/inactive states specific to employees diff --git a/apps/app/src/lib/org-chart.ts b/apps/app/src/lib/org-chart.ts new file mode 100644 index 000000000..b73d809a3 --- /dev/null +++ b/apps/app/src/lib/org-chart.ts @@ -0,0 +1,48 @@ +import { db, Prisma } from '@db'; + +/** + * Removes a member's node (and all connected edges) from the org chart. + * No-op if the org chart doesn't exist or the member has no node on it. + */ +export async function removeMemberFromOrgChart( + organizationId: string, + memberId: string, +): Promise { + const orgChart = await db.organizationChart.findUnique({ + where: { organizationId }, + }); + + if (!orgChart) return; + + const chartNodes = (Array.isArray(orgChart.nodes) ? orgChart.nodes : []) as Array< + Record + >; + const chartEdges = (Array.isArray(orgChart.edges) ? orgChart.edges : []) as Array< + Record + >; + + const removedNodeIds = new Set( + chartNodes + .filter((n) => { + const data = n.data as Record | undefined; + return data?.memberId === memberId; + }) + .map((n) => n.id as string), + ); + + if (removedNodeIds.size === 0) return; + + const updatedNodes = chartNodes.filter((n) => !removedNodeIds.has(n.id as string)); + const updatedEdges = chartEdges.filter( + (e) => + !removedNodeIds.has(e.source as string) && !removedNodeIds.has(e.target as string), + ); + + await db.organizationChart.update({ + where: { organizationId }, + data: { + nodes: updatedNodes as unknown as Prisma.InputJsonValue, + edges: updatedEdges as unknown as Prisma.InputJsonValue, + }, + }); +} diff --git a/apps/app/src/test-utils/mocks/auth.ts b/apps/app/src/test-utils/mocks/auth.ts index dbc2307bd..e275f516c 100644 --- a/apps/app/src/test-utils/mocks/auth.ts +++ b/apps/app/src/test-utils/mocks/auth.ts @@ -72,6 +72,7 @@ export const createMockMember = (overrides?: Partial): Member => ({ department: Departments.none, isActive: true, fleetDmLabelId: null, + jobTitle: null, deactivated: false, ...overrides, }); diff --git a/bun.lock b/bun.lock index f8ccf00e0..fd430dc28 100644 --- a/bun.lock +++ b/bun.lock @@ -222,6 +222,7 @@ "@vercel/analytics": "^1.5.0", "@vercel/sandbox": "^0.0.21", "@vercel/sdk": "^1.7.1", + "@xyflow/react": "^12.10.0", "ai": "^5.0.108", "ai-elements": "^1.6.1", "axios": "^1.9.0", diff --git a/packages/db/prisma/migrations/20260211165134_add_organization_chart/migration.sql b/packages/db/prisma/migrations/20260211165134_add_organization_chart/migration.sql new file mode 100644 index 000000000..76c9e09c4 --- /dev/null +++ b/packages/db/prisma/migrations/20260211165134_add_organization_chart/migration.sql @@ -0,0 +1,23 @@ +-- CreateTable +CREATE TABLE "OrganizationChart" ( + "id" TEXT NOT NULL DEFAULT generate_prefixed_cuid('och'::text), + "organizationId" TEXT NOT NULL, + "name" TEXT NOT NULL DEFAULT 'Organization Chart', + "type" TEXT NOT NULL DEFAULT 'interactive', + "nodes" JSONB NOT NULL DEFAULT '[]', + "edges" JSONB NOT NULL DEFAULT '[]', + "uploadedImageUrl" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "OrganizationChart_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "OrganizationChart_organizationId_key" ON "OrganizationChart"("organizationId"); + +-- CreateIndex +CREATE INDEX "OrganizationChart_organizationId_idx" ON "OrganizationChart"("organizationId"); + +-- AddForeignKey +ALTER TABLE "OrganizationChart" ADD CONSTRAINT "OrganizationChart_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20260211170222_add_member_job_title/migration.sql b/packages/db/prisma/migrations/20260211170222_add_member_job_title/migration.sql new file mode 100644 index 000000000..1ec0ce8b3 --- /dev/null +++ b/packages/db/prisma/migrations/20260211170222_add_member_job_title/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Member" ADD COLUMN "jobTitle" TEXT; diff --git a/packages/db/prisma/schema/auth.prisma b/packages/db/prisma/schema/auth.prisma index 64eca15af..f248ff189 100644 --- a/packages/db/prisma/schema/auth.prisma +++ b/packages/db/prisma/schema/auth.prisma @@ -96,6 +96,7 @@ model Member { createdAt DateTime @default(now()) department Departments @default(none) + jobTitle String? isActive Boolean @default(true) deactivated Boolean @default(false) employeeTrainingVideoCompletion EmployeeTrainingVideoCompletion[] diff --git a/packages/db/prisma/schema/org-chart.prisma b/packages/db/prisma/schema/org-chart.prisma new file mode 100644 index 000000000..2ef7e2998 --- /dev/null +++ b/packages/db/prisma/schema/org-chart.prisma @@ -0,0 +1,14 @@ +model OrganizationChart { + id String @id @default(dbgenerated("generate_prefixed_cuid('och'::text)")) + organizationId String @unique + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + name String @default("Organization Chart") + type String @default("interactive") // "interactive" or "uploaded" + nodes Json @default("[]") + edges Json @default("[]") + uploadedImageUrl String? // S3 key when type="uploaded" + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([organizationId]) +} diff --git a/packages/db/prisma/schema/organization.prisma b/packages/db/prisma/schema/organization.prisma index c673990db..45cbce5b5 100644 --- a/packages/db/prisma/schema/organization.prisma +++ b/packages/db/prisma/schema/organization.prisma @@ -62,5 +62,8 @@ model Organization { // Findings findings Finding[] + // Org Chart + organizationChart OrganizationChart? + @@index([slug]) } diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index 490dfe89f..e6232de5c 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -15596,6 +15596,132 @@ "Training" ] } + }, + "/v1/org-chart": { + "get": { + "operationId": "OrgChartController_getOrgChart_v1", + "parameters": [ + { + "name": "X-Organization-Id", + "in": "header", + "description": "Organization ID (required for session auth, optional for API key auth)", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The organization chart" + } + }, + "security": [ + { + "apikey": [] + } + ], + "summary": "Get the organization chart", + "tags": [ + "Org Chart" + ] + }, + "put": { + "operationId": "OrgChartController_upsertOrgChart_v1", + "parameters": [ + { + "name": "X-Organization-Id", + "in": "header", + "description": "Organization ID (required for session auth, optional for API key auth)", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The saved organization chart" + } + }, + "security": [ + { + "apikey": [] + } + ], + "summary": "Create or update an interactive organization chart", + "tags": [ + "Org Chart" + ] + }, + "delete": { + "operationId": "OrgChartController_deleteOrgChart_v1", + "parameters": [ + { + "name": "X-Organization-Id", + "in": "header", + "description": "Organization ID (required for session auth, optional for API key auth)", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Deletion confirmation" + } + }, + "security": [ + { + "apikey": [] + } + ], + "summary": "Delete the organization chart", + "tags": [ + "Org Chart" + ] + } + }, + "/v1/org-chart/upload": { + "post": { + "operationId": "OrgChartController_uploadOrgChart_v1", + "parameters": [ + { + "name": "X-Organization-Id", + "in": "header", + "description": "Organization ID (required for session auth, optional for API key auth)", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UploadOrgChartDto" + } + } + } + }, + "responses": { + "201": { + "description": "The uploaded organization chart" + } + }, + "security": [ + { + "apikey": [] + } + ], + "summary": "Upload an image as the organization chart", + "tags": [ + "Org Chart" + ] + } } }, "info": { @@ -15727,6 +15853,12 @@ ], "example": "it" }, + "jobTitle": { + "type": "object", + "description": "Job title for the member", + "example": "Software Engineer", + "nullable": true + }, "isActive": { "type": "boolean", "description": "Whether member is active", @@ -15754,6 +15886,7 @@ "role", "createdAt", "department", + "jobTitle", "isActive", "fleetDmLabelId", "user" @@ -15795,6 +15928,11 @@ "type": "number", "description": "FleetDM label ID for member devices", "example": 123 + }, + "jobTitle": { + "type": "string", + "description": "Job title for the member", + "example": "Software Engineer" } }, "required": [ @@ -15868,6 +16006,11 @@ "type": "number", "description": "FleetDM label ID for member devices", "example": 123 + }, + "jobTitle": { + "type": "string", + "description": "Job title for the member", + "example": "Software Engineer" } } }, @@ -19371,6 +19514,28 @@ "required": [ "sent" ] + }, + "UploadOrgChartDto": { + "type": "object", + "properties": { + "fileName": { + "type": "string", + "description": "Original file name" + }, + "fileType": { + "type": "string", + "description": "MIME type of the file (e.g. image/png)" + }, + "fileData": { + "type": "string", + "description": "Base64-encoded file data" + } + }, + "required": [ + "fileName", + "fileType", + "fileData" + ] } } } From eb2957fe9f52ea3c97b0b091e304bc4804bb6c95 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:58:33 -0500 Subject: [PATCH 6/7] fix(automation): clarify automation agent's data retrieval capabilities (#2129) Co-authored-by: Tofik Hasanov --- .../[automationId]/actions/prompts/automation-suggestions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/actions/prompts/automation-suggestions.ts b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/actions/prompts/automation-suggestions.ts index f49dd1ce0..15029004e 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/actions/prompts/automation-suggestions.ts +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/automation/[automationId]/actions/prompts/automation-suggestions.ts @@ -30,7 +30,7 @@ The suggestions should help collect evidence that directly relates to verifying export const AUTOMATION_SUGGESTIONS_SYSTEM_PROMPT = `You are an expert at creating automation suggestions for compliance evidence collection. IMPORTANT CONTEXT: -- The automation agent can ONLY read and fetch data from APIs (no write/modify operations) +- The automation agent can ONLY read and fetch data from APIs (no write/modify operations). Some APIs require POST for data retrieval — this is allowed when POST is used solely for querying/fetching data, not for creating or modifying resources. - The agent will write an integration with any API to pull necessary evidence to verify compliance - These are read-only evidence collection automations that check compliance status - Automations connect to vendor APIs/integrations to programmatically fetch data - NO screenshots or manual collection From 7f793512731cecf873b765b535d28bc1c5da4fea Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 12 Feb 2026 16:51:11 -0500 Subject: [PATCH 7/7] fix: policy version API content bug + published version protection (#2130) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(api): fix policy version content stored as empty arrays via API class-transformer with enableImplicitConversion was converting TipTap node objects to empty arrays when processing content: unknown[] DTO fields. Added @Transform decorator to preserve raw values. Also: - Block content updates on published policies via PATCH /policies/:id - Align updateVersionContent guard with UI (only block current version when published) - Sync content to current version when updating via PATCH /policies/:id - Add GET /policies/:id/versions/:versionId endpoint - Add Swagger docs for new endpoint Co-Authored-By: Claude Opus 4.6 * fix(app): allow PDF upload/delete on draft policy versions and fix false success toast The upload and delete PDF guards blocked all operations on the current version regardless of policy status. Now only blocks when policy is actually published (matching the pattern used everywhere else). Also fixed PdfViewer onSuccess handlers to check result.data.success before showing the success toast — previously showed "PDF uploaded successfully" even when the server action returned { success: false }. Co-Authored-By: Claude Opus 4.6 * fix(api,app): protect current version during needs_review status and fix stale pointer Change version mutation guards from `status === 'published'` to `status !== 'draft'` so that the current version is also protected when the policy is in needs_review state. Fix stale currentVersionId in updateById by reading it inside the transaction. Co-Authored-By: Claude Opus 4.6 * fix(api): move status guard inside transaction to prevent concurrent publish bypass The draft-only content guard was reading policy status before the transaction, allowing a concurrent publish to bypass the check. Now the existence check and status guard both run inside the transaction. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Tofik Hasanov Co-authored-by: Claude Opus 4.6 --- .../src/policies/dto/ai-suggest-policy.dto.ts | 2 + .../api/src/policies/dto/create-policy.dto.ts | 2 + apps/api/src/policies/dto/version.dto.ts | 2 + apps/api/src/policies/policies.controller.ts | 32 ++++ apps/api/src/policies/policies.service.ts | 152 +++++++++++++----- .../policies/schemas/version-operations.ts | 5 + .../src/policies/schemas/version-responses.ts | 24 +++ .../policies/update-version-content.ts | 5 +- .../[policyId]/actions/delete-policy-pdf.ts | 10 +- .../[policyId]/actions/upload-policy-pdf.ts | 10 +- .../[policyId]/components/PdfViewer.tsx | 24 ++- packages/docs/openapi.json | 101 ++++++++++++ 12 files changed, 310 insertions(+), 59 deletions(-) diff --git a/apps/api/src/policies/dto/ai-suggest-policy.dto.ts b/apps/api/src/policies/dto/ai-suggest-policy.dto.ts index 351a83801..cc2f744d5 100644 --- a/apps/api/src/policies/dto/ai-suggest-policy.dto.ts +++ b/apps/api/src/policies/dto/ai-suggest-policy.dto.ts @@ -1,4 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; import { IsString, IsOptional, IsArray } from 'class-validator'; export class AISuggestPolicyRequestDto { @@ -29,5 +30,6 @@ export class AISuggestPolicyRequestDto { }) @IsOptional() @IsArray() + @Transform(({ value }) => value) chatHistory?: Array<{ role: 'user' | 'assistant'; content: string }>; } diff --git a/apps/api/src/policies/dto/create-policy.dto.ts b/apps/api/src/policies/dto/create-policy.dto.ts index 776a0763e..0aa576e23 100644 --- a/apps/api/src/policies/dto/create-policy.dto.ts +++ b/apps/api/src/policies/dto/create-policy.dto.ts @@ -1,4 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; import { IsString, IsOptional, @@ -81,6 +82,7 @@ export class CreatePolicyDto { items: { type: 'object', additionalProperties: true }, }) @IsArray() + @Transform(({ value }) => value) content: unknown[]; @ApiProperty({ diff --git a/apps/api/src/policies/dto/version.dto.ts b/apps/api/src/policies/dto/version.dto.ts index d0f894f95..b69297e02 100644 --- a/apps/api/src/policies/dto/version.dto.ts +++ b/apps/api/src/policies/dto/version.dto.ts @@ -1,4 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; import { IsArray, IsBoolean, IsOptional, IsString } from 'class-validator'; export class CreateVersionDto { @@ -36,6 +37,7 @@ export class UpdateVersionContentDto { items: { type: 'object', additionalProperties: true }, }) @IsArray() + @Transform(({ value }) => value) content: unknown[]; } diff --git a/apps/api/src/policies/policies.controller.ts b/apps/api/src/policies/policies.controller.ts index 2f27305fd..9ff053d71 100644 --- a/apps/api/src/policies/policies.controller.ts +++ b/apps/api/src/policies/policies.controller.ts @@ -52,6 +52,7 @@ import { VERSION_BODIES } from './schemas/version-bodies'; import { CREATE_POLICY_VERSION_RESPONSES, DELETE_VERSION_RESPONSES, + GET_POLICY_VERSION_BY_ID_RESPONSES, GET_POLICY_VERSIONS_RESPONSES, PUBLISH_VERSION_RESPONSES, SET_ACTIVE_VERSION_RESPONSES, @@ -265,6 +266,37 @@ export class PoliciesController { }; } + @Get(':id/versions/:versionId') + @ApiOperation(VERSION_OPERATIONS.getPolicyVersionById) + @ApiParam(VERSION_PARAMS.policyId) + @ApiParam(VERSION_PARAMS.versionId) + @ApiResponse(GET_POLICY_VERSION_BY_ID_RESPONSES[200]) + @ApiResponse(GET_POLICY_VERSION_BY_ID_RESPONSES[401]) + @ApiResponse(GET_POLICY_VERSION_BY_ID_RESPONSES[404]) + async getPolicyVersionById( + @Param('id') id: string, + @Param('versionId') versionId: string, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + const data = await this.policiesService.getVersionById( + id, + versionId, + organizationId, + ); + + return { + data, + authType: authContext.authType, + ...(authContext.userId && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), + }; + } + @Post(':id/versions') @ApiOperation(VERSION_OPERATIONS.createPolicyVersion) @ApiParam(VERSION_PARAMS.policyId) diff --git a/apps/api/src/policies/policies.service.ts b/apps/api/src/policies/policies.service.ts index a6cc5e961..cc2b019de 100644 --- a/apps/api/src/policies/policies.service.ts +++ b/apps/api/src/policies/policies.service.ts @@ -222,19 +222,6 @@ export class PoliciesService { updateData: UpdatePolicyDto, ) { try { - // First check if the policy exists and belongs to the organization - const existingPolicy = await db.policy.findFirst({ - where: { - id, - organizationId, - }, - select: { id: true, name: true }, - }); - - if (!existingPolicy) { - throw new NotFoundException(`Policy with ID ${id} not found`); - } - // Prepare update data with special handling for status changes const updatePayload: Record = { ...updateData }; @@ -249,35 +236,70 @@ export class PoliciesService { } // Coerce content to Prisma JSON[] input if provided - if (Array.isArray(updateData.content)) { - updatePayload.content = updateData.content as Prisma.InputJsonValue[]; + const contentValue = Array.isArray(updateData.content) + ? (updateData.content as Prisma.InputJsonValue[]) + : null; + + if (contentValue) { + updatePayload.content = contentValue; } - // Update the policy - const updatedPolicy = await db.policy.update({ - where: { id }, - data: updatePayload, - select: { - id: true, - name: true, - description: true, - status: true, - content: true, - frequency: true, - department: true, - isRequiredToSign: true, - signedBy: true, - reviewDate: true, - isArchived: true, - createdAt: true, - updatedAt: true, - lastArchivedAt: true, - lastPublishedAt: true, - organizationId: true, - assigneeId: true, - approverId: true, - policyTemplateId: true, - }, + // All reads and writes in one transaction to prevent concurrent publish bypass + const updatedPolicy = await db.$transaction(async (tx) => { + // Check existence and status inside the transaction + const existingPolicy = await tx.policy.findFirst({ + where: { id, organizationId }, + select: { id: true, status: true }, + }); + + if (!existingPolicy) { + throw new NotFoundException(`Policy with ID ${id} not found`); + } + + // Cannot update content unless policy is in draft status + // This covers both 'published' and 'needs_review' states + if (contentValue && existingPolicy.status !== 'draft') { + throw new BadRequestException( + 'Cannot update content of a published policy. Create a new version to make changes.', + ); + } + + const policy = await tx.policy.update({ + where: { id }, + data: updatePayload, + select: { + id: true, + name: true, + description: true, + status: true, + content: true, + frequency: true, + department: true, + isRequiredToSign: true, + signedBy: true, + reviewDate: true, + isArchived: true, + createdAt: true, + updatedAt: true, + lastArchivedAt: true, + lastPublishedAt: true, + organizationId: true, + assigneeId: true, + approverId: true, + policyTemplateId: true, + currentVersionId: true, + }, + }); + + // Keep current version content in sync with policy content + if (contentValue && policy.currentVersionId) { + await tx.policyVersion.update({ + where: { id: policy.currentVersionId }, + data: { content: contentValue }, + }); + } + + return policy; }); this.logger.log(`Updated policy: ${updatedPolicy.name} (${id})`); @@ -361,6 +383,48 @@ export class PoliciesService { } } + async getVersionById( + policyId: string, + versionId: string, + organizationId: string, + ) { + const policy = await db.policy.findFirst({ + where: { id: policyId, organizationId }, + select: { id: true, currentVersionId: true, pendingVersionId: true }, + }); + + if (!policy) { + throw new NotFoundException(`Policy with ID ${policyId} not found`); + } + + const version = await db.policyVersion.findUnique({ + where: { id: versionId }, + include: { + publishedBy: { + include: { + user: { + select: { + id: true, + name: true, + image: true, + }, + }, + }, + }, + }, + }); + + if (!version || version.policyId !== policyId) { + throw new NotFoundException('Version not found'); + } + + return { + version, + currentVersionId: policy.currentVersionId, + pendingVersionId: policy.pendingVersionId, + }; + } + async getVersions(policyId: string, organizationId: string) { const policy = await db.policy.findFirst({ where: { id: policyId, organizationId }, @@ -530,6 +594,7 @@ export class PoliciesService { select: { id: true, organizationId: true, + status: true, currentVersionId: true, pendingVersionId: true, }, @@ -545,7 +610,12 @@ export class PoliciesService { throw new NotFoundException('Version not found'); } - if (version.id === version.policy.currentVersionId) { + // Cannot edit the current version unless the policy is in draft status + // This covers both 'published' and 'needs_review' states + if ( + version.id === version.policy.currentVersionId && + version.policy.status !== 'draft' + ) { throw new BadRequestException( 'Cannot edit the published version. Create a new version to make changes.', ); diff --git a/apps/api/src/policies/schemas/version-operations.ts b/apps/api/src/policies/schemas/version-operations.ts index a8c52e629..29273615f 100644 --- a/apps/api/src/policies/schemas/version-operations.ts +++ b/apps/api/src/policies/schemas/version-operations.ts @@ -6,6 +6,11 @@ export const VERSION_OPERATIONS: Record = { description: 'Returns all versions for a policy in descending order. Supports both API key authentication and session authentication.', }, + getPolicyVersionById: { + summary: 'Get policy version by ID', + description: + 'Returns a single policy version by its ID, including content and metadata.', + }, createPolicyVersion: { summary: 'Create policy version', description: diff --git a/apps/api/src/policies/schemas/version-responses.ts b/apps/api/src/policies/schemas/version-responses.ts index 18b21bd3f..8281071ae 100644 --- a/apps/api/src/policies/schemas/version-responses.ts +++ b/apps/api/src/policies/schemas/version-responses.ts @@ -43,6 +43,30 @@ const BAD_REQUEST_RESPONSE: ApiResponseOptions = { }, }; +export const GET_POLICY_VERSION_BY_ID_RESPONSES: Record< + string, + ApiResponseOptions +> = { + 200: { + status: 200, + description: 'Policy version retrieved successfully', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + version: { type: 'object' }, + currentVersionId: { type: 'string', nullable: true }, + pendingVersionId: { type: 'string', nullable: true }, + }, + }, + }, + }, + }, + 401: UNAUTHORIZED_RESPONSE, + 404: NOT_FOUND_RESPONSE, +}; + export const GET_POLICY_VERSIONS_RESPONSES: Record = { 200: { diff --git a/apps/app/src/actions/policies/update-version-content.ts b/apps/app/src/actions/policies/update-version-content.ts index cccf660e5..eb43a6973 100644 --- a/apps/app/src/actions/policies/update-version-content.ts +++ b/apps/app/src/actions/policies/update-version-content.ts @@ -105,8 +105,9 @@ export const updateVersionContentAction = authActionClient return { success: false, error: 'Version does not belong to this policy' }; } - // Cannot edit published version (only if the policy is actually published) - if (version.id === version.policy.currentVersionId && version.policy.status === PolicyStatus.published) { + // Cannot edit the current version unless the policy is in draft status + // This covers both 'published' and 'needs_review' states + if (version.id === version.policy.currentVersionId && version.policy.status !== PolicyStatus.draft) { return { success: false, error: 'Cannot edit the published version. Create a new version to make changes.', diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/actions/delete-policy-pdf.ts b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/actions/delete-policy-pdf.ts index 001bdb932..6fbd681ac 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/actions/delete-policy-pdf.ts +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/actions/delete-policy-pdf.ts @@ -34,8 +34,9 @@ export const deletePolicyPdfAction = authActionClient // Verify policy belongs to organization const policy = await db.policy.findUnique({ where: { id: policyId, organizationId }, - select: { - id: true, + select: { + id: true, + status: true, pdfUrl: true, currentVersionId: true, pendingVersionId: true, @@ -59,8 +60,9 @@ export const deletePolicyPdfAction = authActionClient return { success: false, error: 'Version not found' }; } - // Don't allow deleting PDF from published or pending versions - if (version.id === policy.currentVersionId) { + // Don't allow deleting PDF from the current version unless policy is in draft + // This covers both 'published' and 'needs_review' states + if (version.id === policy.currentVersionId && policy.status !== 'draft') { return { success: false, error: 'Cannot delete PDF from the published version' }; } if (version.id === policy.pendingVersionId) { diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/actions/upload-policy-pdf.ts b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/actions/upload-policy-pdf.ts index 23f06d603..fd2205167 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/actions/upload-policy-pdf.ts +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/actions/upload-policy-pdf.ts @@ -41,8 +41,9 @@ export const uploadPolicyPdfAction = authActionClient // Verify policy belongs to organization const policy = await db.policy.findUnique({ where: { id: policyId, organizationId }, - select: { - id: true, + select: { + id: true, + status: true, pdfUrl: true, currentVersionId: true, pendingVersionId: true, @@ -66,8 +67,9 @@ export const uploadPolicyPdfAction = authActionClient return { success: false, error: 'Version not found' }; } - // Don't allow uploading PDF to published or pending versions - if (version.id === policy.currentVersionId) { + // Don't allow uploading PDF to the current version unless policy is in draft + // This covers both 'published' and 'needs_review' states + if (version.id === policy.currentVersionId && policy.status !== 'draft') { return { success: false, error: 'Cannot upload PDF to the published version' }; } if (version.id === policy.pendingVersionId) { diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PdfViewer.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PdfViewer.tsx index 8b4c7cfcf..b08122803 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PdfViewer.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PdfViewer.tsx @@ -97,19 +97,27 @@ export function PdfViewer({ }, [pdfUrl, policyId, versionId, getUrl]); const { execute: upload, status: uploadStatus } = useAction(uploadPolicyPdfAction, { - onSuccess: () => { - toast.success('PDF uploaded successfully.'); - setFiles([]); - onMutate?.(); + onSuccess: (result) => { + if (result?.data?.success) { + toast.success('PDF uploaded successfully.'); + setFiles([]); + onMutate?.(); + } else { + toast.error(result?.data?.error || 'Failed to upload PDF.'); + } }, onError: (error) => toast.error(error.error.serverError || 'Failed to upload PDF.'), }); const { execute: deletePdf, status: deleteStatus } = useAction(deletePolicyPdfAction, { - onSuccess: () => { - toast.success('PDF deleted successfully.'); - setSignedUrl(null); - onMutate?.(); + onSuccess: (result) => { + if (result?.data?.success) { + toast.success('PDF deleted successfully.'); + setSignedUrl(null); + onMutate?.(); + } else { + toast.error(result?.data?.error || 'Failed to delete PDF.'); + } }, onError: (error) => toast.error(error.error.serverError || 'Failed to delete PDF.'), }); diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index e6232de5c..7eb1ee6d1 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -6100,6 +6100,107 @@ } }, "/v1/policies/{id}/versions/{versionId}": { + "get": { + "description": "Returns a single policy version by its ID, including content and metadata.", + "operationId": "PoliciesController_getPolicyVersionById_v1", + "parameters": [ + { + "name": "X-Organization-Id", + "in": "header", + "description": "Organization ID (required for session auth, optional for API key auth)", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "id", + "required": true, + "in": "path", + "description": "Policy ID", + "schema": { + "type": "string", + "example": "pol_abc123def456" + } + }, + { + "name": "versionId", + "required": true, + "in": "path", + "description": "Policy version ID", + "schema": { + "type": "string", + "example": "pv_abc123def456" + } + } + ], + "responses": { + "200": { + "description": "Policy version retrieved successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "version": { + "type": "object" + }, + "currentVersionId": { + "type": "string", + "nullable": true + }, + "pendingVersionId": { + "type": "string", + "nullable": true + } + } + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Unauthorized" + } + } + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Resource not found" + } + } + } + } + } + } + }, + "security": [ + { + "apikey": [] + } + ], + "summary": "Get policy version by ID", + "tags": [ + "Policies" + ] + }, "patch": { "description": "Updates content for a non-published, non-pending version. Published and pending versions are immutable.", "operationId": "PoliciesController_updateVersionContent_v1",