From f44d36f651be3d288ce6caf4c7a0d126be0c989b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 11 Sep 2025 11:22:44 -0400 Subject: [PATCH 1/2] ENG-13 Ability to download all into one doc (#1474) * add ability to download a policy as pdf * add Version table at the bottom of policy pdf * download all policies into one pdf * make sure each policy starts in a new page on pdf --------- Co-authored-by: chasprowebdev Co-authored-by: Mariano Fuentes --- apps/app/package.json | 2 + .../components/PolicyHeaderActions.tsx | 46 +- .../[orgId]/policies/[policyId]/page.tsx | 2 +- .../all/components/policies-table.tsx | 46 ++ apps/app/src/lib/pdf-generator.ts | 707 ++++++++++++++++++ 5 files changed, 799 insertions(+), 4 deletions(-) create mode 100644 apps/app/src/lib/pdf-generator.ts diff --git a/apps/app/package.json b/apps/app/package.json index a3f25721d..76ede1977 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -60,6 +60,7 @@ "dub": "^0.66.1", "framer-motion": "^12.18.1", "geist": "^1.3.1", + "jspdf": "^3.0.2", "lucide-react": "^0.534.0", "motion": "^12.9.2", "next": "^15.4.6", @@ -103,6 +104,7 @@ "@testing-library/react": "^16.3.0", "@trigger.dev/build": "4", "@types/d3": "^7.4.3", + "@types/jspdf": "^2.0.0", "@types/node": "^24.0.3", "@vitejs/plugin-react": "^4.6.0", "@vitest/ui": "^3.2.4", diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyHeaderActions.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyHeaderActions.tsx index 0cff36cbb..6119ad09c 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyHeaderActions.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyHeaderActions.tsx @@ -1,6 +1,7 @@ 'use client'; import { regeneratePolicyAction } from '@/app/(app)/[orgId]/policies/[policyId]/actions/regenerate-policy'; +import { generatePolicyPDF } from '@/lib/pdf-generator'; import { Button } from '@comp/ui/button'; import { Dialog, @@ -18,19 +19,53 @@ import { DropdownMenuTrigger, } from '@comp/ui/dropdown-menu'; import { Icons } from '@comp/ui/icons'; -import { Policy } from '@db'; +import type { Policy, Member, User } from '@db'; +import type { JSONContent } from '@tiptap/react'; import { useAction } from 'next-safe-action/hooks'; import { useState } from 'react'; import { toast } from 'sonner'; +import { AuditLogWithRelations } from '../data'; -export function PolicyHeaderActions({ policy }: { policy: Policy | null }) { +export function PolicyHeaderActions({ + policy, + logs +}: { + policy: (Policy & { approver: (Member & { user: User }) | null }) | null; + logs: AuditLogWithRelations[]; +}) { const [isRegenerateConfirmOpen, setRegenerateConfirmOpen] = useState(false); - + // Delete flows through query param to existing dialog in PolicyOverview const regenerate = useAction(regeneratePolicyAction, { onSuccess: () => toast.success('Regeneration triggered. This may take a moment.'), onError: () => toast.error('Failed to trigger policy regeneration'), }); + const handleDownloadPDF = () => { + try { + if (!policy || !policy.content) { + toast.error('Policy content not available for download'); + return; + } + + // Convert policy content to JSONContent array if needed + let policyContent: JSONContent[]; + if (Array.isArray(policy.content)) { + policyContent = policy.content as JSONContent[]; + } else if (typeof policy.content === 'object' && policy.content !== null) { + policyContent = [policy.content as JSONContent]; + } else { + toast.error('Invalid policy content format'); + return; + } + + // Generate and download the PDF + generatePolicyPDF(policyContent as any, logs, policy.name || 'Policy Document'); + } catch (error) { + console.error('Error downloading policy PDF:', error); + toast.error('Failed to generate policy PDF'); + } + }; + if (!policy) return null; const isPendingApproval = !!policy.approverId; @@ -57,6 +92,11 @@ export function PolicyHeaderActions({ policy }: { policy: Policy | null }) { Edit policy + handleDownloadPDF()} + > + Download as PDF + { const url = new URL(window.location.href); diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/page.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/page.tsx index 686220124..f1892e377 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/page.tsx @@ -24,7 +24,7 @@ export default async function PolicyDetails({ { label: 'Policies', href: `/${orgId}/policies/all` }, { label: policy?.name ?? 'Policy', current: true }, ]} - headerRight={} + headerRight={} > >]>; @@ -16,6 +20,7 @@ interface PoliciesTableProps { export function PoliciesTable({ promises }: PoliciesTableProps) { const [{ data, pageCount }] = React.use(promises); + const [isDownloadingAll, setIsDownloadingAll] = React.useState(false); const params = useParams(); const orgId = params.orgId as string; @@ -33,11 +38,52 @@ export function PoliciesTable({ promises }: PoliciesTableProps) { clearOnDefault: true, }); + const handleDownloadAll = () => { + setIsDownloadingAll(true); + // Fetch logs for all policies + const fetchAllLogs = async () => { + const logsEntries = await Promise.all( + data.map(async (policy) => { + const logs = await getLogsForPolicy(policy.id); + return [policy.id, logs] as const; + }) + ); + // Convert array of entries to an object + return Object.fromEntries(logsEntries); + }; + + // Since handleDownloadAll is not async, we need to handle the async logic here + fetchAllLogs().then((policyLogs) => { + setIsDownloadingAll(false); + downloadAllPolicies(data, policyLogs); + }); + } + return ( <> row.id} rowClickBasePath={`/${orgId}/policies`}> {/* */} + {data.length > 0 && ( + + )} diff --git a/apps/app/src/lib/pdf-generator.ts b/apps/app/src/lib/pdf-generator.ts new file mode 100644 index 000000000..bbba4dbb9 --- /dev/null +++ b/apps/app/src/lib/pdf-generator.ts @@ -0,0 +1,707 @@ +import { jsPDF } from 'jspdf'; +import type { JSONContent as TipTapJSONContent } from '@tiptap/react'; +import { AuditLog, User, Member, Organization, Policy } from '@db'; +import { format } from 'date-fns'; + +// Type definition for the JSON content structure +interface JSONContent { + type: string; + attrs?: Record; + content?: JSONContent[]; + text?: string; + marks?: Array<{ type: string }>; +} + +type AuditLogWithRelations = AuditLog & { + user: User | null; + member: Member | null; + organization: Organization; +}; + +// Shared PDF configuration +interface PDFConfig { + doc: any; + pageWidth: number; + pageHeight: number; + margin: number; + contentWidth: number; + lineHeight: number; + defaultFontSize: number; + yPosition: number; +} + +// Helper function to clean text for PDF rendering +const cleanTextForPDF = (text: string): string => { + // First, handle specific problematic characters that cause font issues + const replacements: { [key: string]: string } = { + '\u2018': "'", // left single quotation mark + '\u2019': "'", // right single quotation mark + '\u201C': '"', // left double quotation mark + '\u201D': '"', // right double quotation mark + '\u2013': '-', // en dash + '\u2014': '-', // em dash + '\u2026': '...', // horizontal ellipsis + '\u2265': '>=', // greater than or equal to (≥) + '\u2264': '<=', // less than or equal to (≤) + '\u00B0': 'deg', // degree symbol (°) + '\u00A9': '(c)', // copyright symbol (©) + '\u00AE': '(R)', // registered trademark (®) + '\u2122': 'TM', // trademark symbol (™) + '\u00A0': ' ', // non-breaking space + '\u2022': '•', // bullet point (ensure consistent bullet) + '\u00B1': '+/-', // plus-minus symbol (±) + '\u00D7': 'x', // multiplication sign (×) + '\u00F7': '/', // division sign (÷) + '\u2192': '->', // right arrow (→) + '\u2190': '<-', // left arrow (←) + '\u2194': '<->', // left-right arrow (↔) + }; + + // Replace known problematic characters + let cleanedText = text; + for (const [unicode, replacement] of Object.entries(replacements)) { + cleanedText = cleanedText.replace(new RegExp(unicode, 'g'), replacement); + } + + // For any remaining non-ASCII characters, try to preserve them first + // Only replace if they cause font rendering issues + return cleanedText.replace(/[^\x00-\x7F]/g, function(char) { + // Common accented characters that should work fine in most PDF fonts + const safeChars = /[àáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞß]/; + + if (safeChars.test(char)) { + return char; // Keep safe accented characters + } + + // For other characters, provide basic ASCII fallbacks + const fallbacks: { [key: string]: string } = { + 'à': 'a', 'á': 'a', 'â': 'a', 'ã': 'a', 'ä': 'a', 'å': 'a', 'æ': 'ae', + 'è': 'e', 'é': 'e', 'ê': 'e', 'ë': 'e', + 'ì': 'i', 'í': 'i', 'î': 'i', 'ï': 'i', + 'ò': 'o', 'ó': 'o', 'ô': 'o', 'õ': 'o', 'ö': 'o', 'ø': 'o', + 'ù': 'u', 'ú': 'u', 'û': 'u', 'ü': 'u', + 'ñ': 'n', 'ç': 'c', 'ß': 'ss', 'ÿ': 'y', + 'À': 'A', 'Á': 'A', 'Â': 'A', 'Ã': 'A', 'Ä': 'A', 'Å': 'A', 'Æ': 'AE', + 'È': 'E', 'É': 'E', 'Ê': 'E', 'Ë': 'E', + 'Ì': 'I', 'Í': 'I', 'Î': 'I', 'Ï': 'I', + 'Ò': 'O', 'Ó': 'O', 'Ô': 'O', 'Õ': 'O', 'Ö': 'O', 'Ø': 'O', + 'Ù': 'U', 'Ú': 'U', 'Û': 'U', 'Ü': 'U', + 'Ñ': 'N', 'Ç': 'C', 'Ý': 'Y' + }; + + return fallbacks[char] || '?'; // Use ? for unknown characters + }); +}; + +// Convert TipTap JSONContent to our internal format +const convertToInternalFormat = (content: TipTapJSONContent[]): JSONContent[] => { + return content.map(item => ({ + type: item.type || 'paragraph', + attrs: item.attrs, + content: item.content ? convertToInternalFormat(item.content) : undefined, + text: item.text, + marks: item.marks + })); +}; + +// Helper function to check for page breaks +const checkPageBreak = (config: PDFConfig, requiredHeight: number = config.lineHeight) => { + if (config.yPosition + requiredHeight > config.pageHeight - config.margin) { + config.doc.addPage(); + config.yPosition = config.margin; + } +}; + +// Helper function to add text with word wrapping +const addTextWithWrapping = ( + config: PDFConfig, + text: string, + fontSize: number = config.defaultFontSize, + isBold: boolean = false +) => { + const cleanText = cleanTextForPDF(text); + + // Always reset font properties and color before setting new ones + config.doc.setFontSize(fontSize); + config.doc.setTextColor(0, 0, 0); // Ensure text is black + if (isBold) { + config.doc.setFont('helvetica', 'bold'); + } else { + config.doc.setFont('helvetica', 'normal'); + } + + const lines = config.doc.splitTextToSize(cleanText, config.contentWidth); + + for (const line of lines) { + checkPageBreak(config); + config.doc.text(line, config.margin, config.yPosition); + config.yPosition += config.lineHeight; + } +}; + +// Helper function to extract text from content array +const extractTextFromContent = (content: JSONContent[]): string => { + return content.map(item => { + if (item.text) { + return item.text; + } else if (item.content) { + return extractTextFromContent(item.content); + } + return ''; + }).join(''); +}; + +// Enhanced helper function that renders text with proper formatting +const renderFormattedContent = ( + config: PDFConfig, + content: JSONContent[], + xPos: number, + maxWidth: number +) => { + for (const item of content) { + if (item.text) { + const isBold = item.marks?.some(mark => mark.type === 'bold') || false; + const cleanText = cleanTextForPDF(item.text); + + config.doc.setFontSize(config.defaultFontSize); + config.doc.setTextColor(0, 0, 0); // Ensure text is black + config.doc.setFont('helvetica', isBold ? 'bold' : 'normal'); + + const lines = config.doc.splitTextToSize(cleanText, maxWidth); + for (const line of lines) { + checkPageBreak(config); + config.doc.text(line, xPos, config.yPosition); + config.yPosition += config.lineHeight; + } + } else if (item.content) { + renderFormattedContent(config, item.content, xPos, maxWidth); + } + } +}; + +// Process JSON content recursively +const processContent = (config: PDFConfig, content: JSONContent[], level: number = 0) => { + for (const item of content) { + switch (item.type) { + case 'heading': + const headingLevel = item.attrs?.level || 1; + let fontSize: number; + let spacingBefore: number; + let spacingAfter: number; + + switch (headingLevel) { + case 1: + fontSize = 14; + spacingBefore = config.lineHeight * 2; + spacingAfter = config.lineHeight; + break; + case 2: + fontSize = 12; + spacingBefore = config.lineHeight * 1.5; + spacingAfter = config.lineHeight * 0.5; + break; + case 3: + fontSize = 11; + spacingBefore = config.lineHeight; + spacingAfter = config.lineHeight * 0.5; + break; + default: + fontSize = config.defaultFontSize; + spacingBefore = config.lineHeight; + spacingAfter = config.lineHeight * 0.5; + } + + config.yPosition += spacingBefore; + checkPageBreak(config); + + if (item.content) { + const headingText = extractTextFromContent(item.content); + addTextWithWrapping(config, headingText, fontSize, true); + } + + config.yPosition += spacingAfter; + break; + + case 'paragraph': + if (item.content) { + const paragraphText = extractTextFromContent(item.content); + if (paragraphText.trim()) { + // Use the enhanced formatting function for paragraphs to handle bold text + const startY = config.yPosition; + renderFormattedContent(config, item.content, config.margin, config.contentWidth); + // Only add spacing if content was actually rendered + if (config.yPosition > startY) { + config.yPosition += config.lineHeight * 0.5; // Small spacing after paragraphs + } + } + } + break; + + case 'bulletList': + if (item.content) { + for (const listItem of item.content) { + if (listItem.type === 'listItem' && listItem.content) { + const listText = extractTextFromContent(listItem.content); + checkPageBreak(config); + + // Add bullet point with consistent font + config.doc.setFontSize(config.defaultFontSize); + config.doc.setFont('helvetica', 'normal'); + config.doc.setTextColor(0, 0, 0); // Ensure bullet is black + config.doc.text('•', config.margin + level * 10, config.yPosition); + + // Add indented text with proper font reset + config.doc.setFontSize(config.defaultFontSize); + config.doc.setFont('helvetica', 'normal'); + config.doc.setTextColor(0, 0, 0); // Ensure text is black + const cleanText = cleanTextForPDF(listText); + const lines = config.doc.splitTextToSize(cleanText, config.contentWidth - 8 - level * 10); + for (let i = 0; i < lines.length; i++) { + checkPageBreak(config); + config.doc.text(lines[i], config.margin + 5 + level * 10, config.yPosition); + config.yPosition += config.lineHeight; + } + config.yPosition += config.lineHeight * 0.3; // Small spacing between list items + } + } + } + break; + + case 'orderedList': + if (item.content) { + let itemNumber = 1; + for (const listItem of item.content) { + if (listItem.type === 'listItem' && listItem.content) { + const listText = extractTextFromContent(listItem.content); + checkPageBreak(config); + + // Add number with consistent font + config.doc.setFontSize(config.defaultFontSize); + config.doc.setFont('helvetica', 'normal'); + config.doc.setTextColor(0, 0, 0); // Ensure number is black + config.doc.text(`${itemNumber}.`, config.margin + level * 10, config.yPosition); + + // Add indented text with proper font reset + config.doc.setFontSize(config.defaultFontSize); + config.doc.setFont('helvetica', 'normal'); + config.doc.setTextColor(0, 0, 0); // Ensure text is black + const cleanText = cleanTextForPDF(listText); + const lines = config.doc.splitTextToSize(cleanText, config.contentWidth - 10 - level * 10); + for (let i = 0; i < lines.length; i++) { + checkPageBreak(config); + config.doc.text(lines[i], config.margin + 8 + level * 10, config.yPosition); + config.yPosition += config.lineHeight; + } + config.yPosition += config.lineHeight * 0.3; // Small spacing between list items + itemNumber++; + } + } + } + break; + } + } +}; + +// Function to add audit logs table +const addAuditLogsTable = (config: PDFConfig, auditLogs: AuditLogWithRelations[], isCompact: boolean = false) => { + checkPageBreak(config, config.lineHeight * 6); // Ensure we have space for at least the header + + // Reset text color to black for table + config.doc.setTextColor(0, 0, 0); + + // Table configuration + const tableStartY = config.yPosition; + const colWidths = { + name: config.contentWidth * 0.25, // 25% for Name + description: config.contentWidth * 0.55, // 55% for Description + datetime: config.contentWidth * 0.20 // 20% for Date/Time + }; + + const colPositions = { + name: config.margin, + description: config.margin + colWidths.name, + datetime: config.margin + colWidths.name + colWidths.description + }; + + // Adjust font sizes based on compact mode + const headerFontSize = isCompact ? 9 : 10; + const contentFontSize = isCompact ? 8 : 9; + + // Draw table header + config.doc.setFontSize(headerFontSize); + config.doc.setFont('helvetica', 'bold'); + + // Header background (light gray) + config.doc.setFillColor(240, 240, 240); + config.doc.rect(config.margin, config.yPosition - 2, config.contentWidth, config.lineHeight + 2, 'F'); + + // Header text + config.doc.setTextColor(0, 0, 0); + config.doc.text('Name', colPositions.name + 2, config.yPosition + 4); + config.doc.text('Description', colPositions.description + 2, config.yPosition + 4); + config.doc.text('Date/Time', colPositions.datetime + 2, config.yPosition + 4); + + config.yPosition += config.lineHeight + 2; + + // Draw table rows + config.doc.setFont('helvetica', 'normal'); + config.doc.setFontSize(contentFontSize); + + auditLogs.forEach((log, index) => { + const rowY = config.yPosition; + + // Check for page break + checkPageBreak(config, config.lineHeight * 2); + + // Alternate row background + if (index % 2 === 0) { + config.doc.setFillColor(248, 248, 248); + config.doc.rect(config.margin, config.yPosition - 1, config.contentWidth, config.lineHeight + 2, 'F'); + } + + // Extract user info + const userName = log.user?.name || `User ${log.userId.substring(0, 6)}`; + const description = log.description || 'No description available'; + const dateTime = format(log.timestamp, 'MMM d, yyyy h:mm a'); + + // Draw cell contents with text wrapping for description + config.doc.setTextColor(0, 0, 0); + + // Name column (truncate if too long) + const nameText = userName.length > 20 ? userName.substring(0, 17) + '...' : userName; + config.doc.text(nameText, colPositions.name + 2, config.yPosition + 4); + + // Description column (wrap text) + const descLines = config.doc.splitTextToSize(description, colWidths.description - 4); + const maxDescLines = 2; // Limit to 2 lines to keep row height manageable + const displayLines = descLines.slice(0, maxDescLines); + + displayLines.forEach((line: string, lineIndex: number) => { + config.doc.text(line, colPositions.description + 2, config.yPosition + 4 + (lineIndex * 4)); + }); + + // If text was truncated, add ellipsis + if (descLines.length > maxDescLines) { + const lastLine = displayLines[displayLines.length - 1]; + const ellipsisLine = lastLine.length > 40 ? lastLine.substring(0, 37) + '...' : lastLine + '...'; + config.doc.text(ellipsisLine, colPositions.description + 2, config.yPosition + 4 + ((maxDescLines - 1) * 4)); + } + + // Date/Time column + config.doc.text(dateTime, colPositions.datetime + 2, config.yPosition + 4); + + // Calculate row height based on description lines + const rowHeight = Math.max(config.lineHeight + 2, (displayLines.length * 4) + 2); + config.yPosition += rowHeight; + + // Draw row border + config.doc.setDrawColor(200, 200, 200); + config.doc.setLineWidth(0.1); + config.doc.line(config.margin, rowY + rowHeight, config.margin + config.contentWidth, rowY + rowHeight); + }); + + // Draw table borders + config.doc.setDrawColor(150, 150, 150); + config.doc.setLineWidth(0.3); + + // Outer border + config.doc.rect(config.margin, tableStartY - 2, config.contentWidth, config.yPosition - (tableStartY - 2)); + + // Column separators + config.doc.line(colPositions.description, tableStartY - 2, colPositions.description, config.yPosition); + config.doc.line(colPositions.datetime, tableStartY - 2, colPositions.datetime, config.yPosition); + + // Add some space after the table + config.yPosition += config.lineHeight; +}; + +// Function to add audit logs section (table or no activity message) +const addAuditLogsSection = (config: PDFConfig, auditLogs: AuditLogWithRelations[], isCompact: boolean = false) => { + // Add some space before the section + config.yPosition += config.lineHeight * 2; + checkPageBreak(config, config.lineHeight * 3); // Ensure we have space for at least the header + + // Add section title + const titleFontSize = isCompact ? 12 : 14; + config.doc.setFontSize(titleFontSize); + config.doc.setFont('helvetica', 'bold'); + config.doc.setTextColor(0, 0, 0); // Ensure title is black + config.doc.text('Recent Activity', config.margin, config.yPosition); + config.yPosition += config.lineHeight * 1.5; + + if (!auditLogs || auditLogs.length === 0) { + // Show "No recent activity" message + const messageFontSize = isCompact ? 9 : 10; + config.doc.setFontSize(messageFontSize); + config.doc.setFont('helvetica', 'normal'); + config.doc.setTextColor(100, 100, 100); // Gray color + config.doc.text('No recent activity', config.margin, config.yPosition); + config.yPosition += config.lineHeight; + + // Reset text color to black after gray message + config.doc.setTextColor(0, 0, 0); + return; + } + + // Show the table + addAuditLogsTable(config, auditLogs, isCompact); +}; + +// Function to add page numbers to all pages +const addPageNumbers = (config: PDFConfig) => { + const totalPages = config.doc.internal.pages.length - 1; // Subtract 1 because of the null page at index 0 + for (let i = 1; i <= totalPages; i++) { + config.doc.setPage(i); + config.doc.setFontSize(8); + config.doc.setFont('helvetica', 'normal'); + config.doc.text(`Page ${i} of ${totalPages}`, config.pageWidth - config.margin - 30, config.pageHeight - 10); + } +}; + +/** + * Converts JSON content to a formatted PDF document + */ +export function generatePolicyPDF(jsonContent: TipTapJSONContent[], logs: AuditLogWithRelations[], policyTitle?: string): void { + const internalContent = convertToInternalFormat(jsonContent); + + const doc = new jsPDF(); + const config: PDFConfig = { + doc, + pageWidth: doc.internal.pageSize.getWidth(), + pageHeight: doc.internal.pageSize.getHeight(), + margin: 20, + contentWidth: doc.internal.pageSize.getWidth() - 40, + lineHeight: 6, + defaultFontSize: 10, + yPosition: 20 + }; + + // Add title if provided + if (policyTitle) { + const cleanTitle = cleanTextForPDF(policyTitle); + + config.doc.setFontSize(16); + config.doc.setFont('helvetica', 'bold'); + config.doc.text(cleanTitle, config.margin, config.yPosition); + config.yPosition += config.lineHeight * 2; + } + + // Process the main content + processContent(config, internalContent); + + // Add audit logs section + addAuditLogsSection(config, logs); + + // Add page numbers + addPageNumbers(config); + + // Save the PDF + const filename = policyTitle + ? `${policyTitle.toLowerCase().replace(/[^a-z0-9]/g, '-')}-policy.pdf` + : 'policy-document.pdf'; + + doc.save(filename); +} + +/** + * Alternative function that generates a more readable HTML-style PDF + */ +export function generatePolicyPDFFromHTML(jsonContent: TipTapJSONContent[], policyTitle?: string): void { + // Convert TipTap JSONContent to our internal format + const convertToInternalFormat = (content: TipTapJSONContent[]): JSONContent[] => { + return content.map(item => ({ + type: item.type || 'paragraph', + attrs: item.attrs, + content: item.content ? convertToInternalFormat(item.content) : undefined, + text: item.text, + marks: item.marks + })); + }; + + const internalContent = convertToInternalFormat(jsonContent); + // Convert JSON to HTML first + const htmlContent = convertJSONToHTML(internalContent); + + // Create a temporary HTML page for PDF generation + const htmlPage = ` + + + + + ${policyTitle || 'Policy Document'} + + + + ${policyTitle ? `

${policyTitle}

` : ''} + ${htmlContent} + + + `; + + // Create a blob and download link + const blob = new Blob([htmlPage], { type: 'text/html' }); + const url = URL.createObjectURL(blob); + + const iframe = document.createElement('iframe'); + iframe.style.display = 'none'; + iframe.src = url; + document.body.appendChild(iframe); + + iframe.onload = () => { + setTimeout(() => { + // Focus the iframe and trigger print dialog (user can save as PDF) + iframe.contentWindow?.focus(); + iframe.contentWindow?.print(); + + // Clean up + setTimeout(() => { + document.body.removeChild(iframe); + URL.revokeObjectURL(url); + }, 1000); + }, 250); + }; +} + +/** + * Convert JSON content to HTML string + */ +function convertJSONToHTML(content: JSONContent[]): string { + return content.map(item => { + switch (item.type) { + case 'heading': + const level = item.attrs?.level || 1; + const headingText = item.content ? extractTextFromContent(item.content) : ''; + return `${headingText}`; + + case 'paragraph': + const paragraphText = item.content ? extractTextFromContent(item.content) : ''; + return `

${paragraphText}

`; + + case 'bulletList': + const bulletItems = item.content?.map(listItem => { + if (listItem.type === 'listItem' && listItem.content) { + const text = extractTextFromContent(listItem.content); + return `
  • ${text}
  • `; + } + return ''; + }).join('') || ''; + return `
      ${bulletItems}
    `; + + case 'orderedList': + const orderedItems = item.content?.map(listItem => { + if (listItem.type === 'listItem' && listItem.content) { + const text = extractTextFromContent(listItem.content); + return `
  • ${text}
  • `; + } + return ''; + }).join('') || ''; + return `
      ${orderedItems}
    `; + + default: + return ''; + } + }).join(''); +} + + +/** + * Downloads all policies into one PDF document + */ +export function downloadAllPolicies( + policies: Policy[], + policyLogs: { [policyId: string]: AuditLogWithRelations[] }, + organizationName?: string +): void { + const doc = new jsPDF(); + const config: PDFConfig = { + doc, + pageWidth: doc.internal.pageSize.getWidth(), + pageHeight: doc.internal.pageSize.getHeight(), + margin: 20, + contentWidth: doc.internal.pageSize.getWidth() - 40, + lineHeight: 6, + defaultFontSize: 10, + yPosition: 20 + }; + + // Add document title + const documentTitle = organizationName ? `${organizationName} - All Policies` : 'All Policies'; + const cleanTitle = cleanTextForPDF(documentTitle); + + config.doc.setFontSize(18); + config.doc.setFont('helvetica', 'bold'); + config.doc.text(cleanTitle, config.margin, config.yPosition); + config.yPosition += config.lineHeight * 3; + + // Process each policy + policies.forEach((policy, index) => { + // Reset text color to black for each policy + config.doc.setTextColor(0, 0, 0); + + // Start each policy on a new page (except the first one) + if (index > 0) { + config.doc.addPage(); + config.yPosition = config.margin; + } + + // Add policy title + if (policy.name) { + const cleanPolicyTitle = cleanTextForPDF(policy.name); + config.doc.setFontSize(16); + config.doc.setFont('helvetica', 'bold'); + config.doc.setTextColor(0, 0, 0); // Ensure title is black + config.doc.text(cleanPolicyTitle, config.margin, config.yPosition); + config.yPosition += config.lineHeight * 2; + } + + // Process policy content + if (policy.content) { + let policyContent: TipTapJSONContent[]; + if (Array.isArray(policy.content)) { + policyContent = policy.content as TipTapJSONContent[]; + } else if (typeof policy.content === 'object' && policy.content !== null) { + policyContent = [policy.content as TipTapJSONContent]; + } else { + // Skip this policy if content format is invalid + return; + } + + const internalContent = convertToInternalFormat(policyContent); + processContent(config, internalContent); + } + + // Add audit logs section for this policy (compact mode) + const logs = policyLogs[policy.id] || []; + addAuditLogsSection(config, logs, true); // true for compact mode + }); + + // Add page numbers + addPageNumbers(config); + + // Save the PDF + const filename = organizationName + ? `${organizationName.toLowerCase().replace(/[^a-z0-9]/g, '-')}-all-policies.pdf` + : 'all-policies.pdf'; + + doc.save(filename); +} From bbe27df28d6fa6dd5688b80aa49c32b72f4ff39a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 11 Sep 2025 11:25:45 -0400 Subject: [PATCH 2/2] COMP-257 [API] - Add following endpoints for People (#1465) * create /v1/people and /v1/people/{id} endpoints * create POST /v1/people endpoint * create PATCH and DELETE endpoints for people * create POST /v1/people/bulk endpoint * move ApiResponses out to their own file * reduce size of people.service.ts file * move all utils function under schema folder in people.module * use db.member.createMany for /people/bulk endpoint --------- Signed-off-by: Mariano Fuentes Co-authored-by: chasprowebdev Co-authored-by: Mariano Fuentes --- apps/api/src/app.module.ts | 2 + .../src/people/dto/bulk-create-people.dto.ts | 32 +++ apps/api/src/people/dto/create-people.dto.ts | 47 ++++ .../src/people/dto/people-responses.dto.ts | 112 +++++++++ apps/api/src/people/dto/update-people.dto.ts | 14 ++ apps/api/src/people/people.controller.ts | 217 ++++++++++++++++++ apps/api/src/people/people.module.ts | 12 + apps/api/src/people/people.service.ts | 178 ++++++++++++++ .../schemas/bulk-create-members.responses.ts | 121 ++++++++++ .../people/schemas/create-member.responses.ts | 51 ++++ .../people/schemas/delete-member.responses.ts | 68 ++++++ .../schemas/get-all-people.responses.ts | 87 +++++++ .../schemas/get-person-by-id.responses.ts | 34 +++ apps/api/src/people/schemas/people-bodies.ts | 19 ++ .../src/people/schemas/people-operations.ts | 34 +++ apps/api/src/people/schemas/people-params.ts | 9 + .../people/schemas/update-member.responses.ts | 52 +++++ apps/api/src/people/utils/member-queries.ts | 160 +++++++++++++ apps/api/src/people/utils/member-validator.ts | 80 +++++++ 19 files changed, 1329 insertions(+) create mode 100644 apps/api/src/people/dto/bulk-create-people.dto.ts create mode 100644 apps/api/src/people/dto/create-people.dto.ts create mode 100644 apps/api/src/people/dto/people-responses.dto.ts create mode 100644 apps/api/src/people/dto/update-people.dto.ts create mode 100644 apps/api/src/people/people.controller.ts create mode 100644 apps/api/src/people/people.module.ts create mode 100644 apps/api/src/people/people.service.ts create mode 100644 apps/api/src/people/schemas/bulk-create-members.responses.ts create mode 100644 apps/api/src/people/schemas/create-member.responses.ts create mode 100644 apps/api/src/people/schemas/delete-member.responses.ts create mode 100644 apps/api/src/people/schemas/get-all-people.responses.ts create mode 100644 apps/api/src/people/schemas/get-person-by-id.responses.ts create mode 100644 apps/api/src/people/schemas/people-bodies.ts create mode 100644 apps/api/src/people/schemas/people-operations.ts create mode 100644 apps/api/src/people/schemas/people-params.ts create mode 100644 apps/api/src/people/schemas/update-member.responses.ts create mode 100644 apps/api/src/people/utils/member-queries.ts create mode 100644 apps/api/src/people/utils/member-validator.ts diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 2b0017eba..2a1a10725 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -5,6 +5,7 @@ import { AppService } from './app.service'; import { AttachmentsModule } from './attachments/attachments.module'; import { AuthModule } from './auth/auth.module'; import { CommentsModule } from './comments/comments.module'; +import { PeopleModule } from './people/people.module'; import { DevicesModule } from './devices/devices.module'; import { DeviceAgentModule } from './device-agent/device-agent.module'; import { awsConfig } from './config/aws.config'; @@ -29,6 +30,7 @@ import { ContextModule } from './context/context.module'; }), AuthModule, OrganizationModule, + PeopleModule, RisksModule, VendorsModule, ContextModule, diff --git a/apps/api/src/people/dto/bulk-create-people.dto.ts b/apps/api/src/people/dto/bulk-create-people.dto.ts new file mode 100644 index 000000000..fa610997b --- /dev/null +++ b/apps/api/src/people/dto/bulk-create-people.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsArray, ValidateNested, ArrayMinSize, ArrayMaxSize } from 'class-validator'; +import { CreatePeopleDto } from './create-people.dto'; + +export class BulkCreatePeopleDto { + @ApiProperty({ + description: 'Array of members to create', + type: [CreatePeopleDto], + example: [ + { + userId: 'usr_abc123def456', + role: 'admin', + department: 'it', + isActive: true, + fleetDmLabelId: 123, + }, + { + userId: 'usr_def456ghi789', + role: 'member', + department: 'hr', + isActive: true, + }, + ], + }) + @IsArray() + @ArrayMinSize(1, { message: 'Members array cannot be empty' }) + @ArrayMaxSize(1000, { message: 'Maximum 1000 members allowed per bulk request' }) + @ValidateNested({ each: true }) + @Type(() => CreatePeopleDto) + members: CreatePeopleDto[]; +} diff --git a/apps/api/src/people/dto/create-people.dto.ts b/apps/api/src/people/dto/create-people.dto.ts new file mode 100644 index 000000000..81aff80ed --- /dev/null +++ b/apps/api/src/people/dto/create-people.dto.ts @@ -0,0 +1,47 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsOptional, IsEnum, IsBoolean, IsNumber } from 'class-validator'; +import { Departments } from '@trycompai/db'; + +export class CreatePeopleDto { + @ApiProperty({ + description: 'User ID to associate with this member', + example: 'usr_abc123def456', + }) + @IsString() + userId: string; + + @ApiProperty({ + description: 'Role for the member', + example: 'admin', + }) + @IsString() + role: string; + + @ApiProperty({ + description: 'Member department', + enum: Departments, + example: Departments.it, + required: false, + }) + @IsOptional() + @IsEnum(Departments) + department?: Departments; + + @ApiProperty({ + description: 'Whether member is active', + example: true, + required: false, + }) + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @ApiProperty({ + description: 'FleetDM label ID for member devices', + example: 123, + required: false, + }) + @IsOptional() + @IsNumber() + fleetDmLabelId?: number; +} diff --git a/apps/api/src/people/dto/people-responses.dto.ts b/apps/api/src/people/dto/people-responses.dto.ts new file mode 100644 index 000000000..be8723a26 --- /dev/null +++ b/apps/api/src/people/dto/people-responses.dto.ts @@ -0,0 +1,112 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Departments } from '@trycompai/db'; + +export class UserResponseDto { + @ApiProperty({ + description: 'User ID', + example: 'usr_abc123def456', + }) + id: string; + + @ApiProperty({ + description: 'User name', + example: 'John Doe', + }) + name: string; + + @ApiProperty({ + description: 'User email', + example: 'john.doe@company.com', + }) + email: string; + + @ApiProperty({ + description: 'Whether email is verified', + example: true, + }) + emailVerified: boolean; + + @ApiProperty({ + description: 'User profile image URL', + example: 'https://example.com/avatar.jpg', + nullable: true, + }) + image: string | null; + + @ApiProperty({ + description: 'When the user was created', + example: '2024-01-01T00:00:00Z', + }) + createdAt: Date; + + @ApiProperty({ + description: 'When the user was last updated', + example: '2024-01-15T00:00:00Z', + }) + updatedAt: Date; + + @ApiProperty({ + description: 'Last login time', + example: '2024-01-15T12:00:00Z', + nullable: true, + }) + lastLogin: Date | null; +} + +export class PeopleResponseDto { + @ApiProperty({ + description: 'Member ID', + example: 'mem_abc123def456', + }) + id: string; + + @ApiProperty({ + description: 'Organization ID this member belongs to', + example: 'org_abc123def456', + }) + organizationId: string; + + @ApiProperty({ + description: 'User ID associated with member', + example: 'usr_abc123def456', + }) + userId: string; + + @ApiProperty({ + description: 'Member role', + example: 'admin', + }) + role: string; + + @ApiProperty({ + description: 'When the member was created', + example: '2024-01-01T00:00:00Z', + }) + createdAt: Date; + + @ApiProperty({ + description: 'Member department', + enum: Departments, + example: Departments.it, + }) + department: Departments; + + @ApiProperty({ + description: 'Whether member is active', + example: true, + }) + isActive: boolean; + + @ApiProperty({ + description: 'FleetDM label ID for member devices', + example: 123, + nullable: true, + }) + fleetDmLabelId: number | null; + + @ApiProperty({ + description: 'User information', + type: UserResponseDto, + }) + user: UserResponseDto; +} diff --git a/apps/api/src/people/dto/update-people.dto.ts b/apps/api/src/people/dto/update-people.dto.ts new file mode 100644 index 000000000..41ac37883 --- /dev/null +++ b/apps/api/src/people/dto/update-people.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty, PartialType } from '@nestjs/swagger'; +import { IsOptional, IsBoolean } from 'class-validator'; +import { CreatePeopleDto } from './create-people.dto'; + +export class UpdatePeopleDto extends PartialType(CreatePeopleDto) { + @ApiProperty({ + description: 'Whether to deactivate this member (soft delete)', + example: false, + required: false, + }) + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/apps/api/src/people/people.controller.ts b/apps/api/src/people/people.controller.ts new file mode 100644 index 000000000..96b03f43e --- /dev/null +++ b/apps/api/src/people/people.controller.ts @@ -0,0 +1,217 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + UseGuards +} from '@nestjs/common'; +import { + ApiBody, + ApiHeader, + ApiOperation, + ApiParam, + ApiResponse, + ApiSecurity, + ApiTags, +} from '@nestjs/swagger'; +import { + AuthContext, + OrganizationId, +} from '../auth/auth-context.decorator'; +import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; +import type { AuthContext as AuthContextType } from '../auth/types'; +import { CreatePeopleDto } from './dto/create-people.dto'; +import { UpdatePeopleDto } from './dto/update-people.dto'; +import { BulkCreatePeopleDto } from './dto/bulk-create-people.dto'; +import { PeopleResponseDto } from './dto/people-responses.dto'; +import { PeopleService } from './people.service'; +import { GET_ALL_PEOPLE_RESPONSES } from './schemas/get-all-people.responses'; +import { CREATE_MEMBER_RESPONSES } from './schemas/create-member.responses'; +import { BULK_CREATE_MEMBERS_RESPONSES } from './schemas/bulk-create-members.responses'; +import { GET_PERSON_BY_ID_RESPONSES } from './schemas/get-person-by-id.responses'; +import { UPDATE_MEMBER_RESPONSES } from './schemas/update-member.responses'; +import { DELETE_MEMBER_RESPONSES } from './schemas/delete-member.responses'; +import { PEOPLE_OPERATIONS } from './schemas/people-operations'; +import { PEOPLE_PARAMS } from './schemas/people-params'; +import { PEOPLE_BODIES } from './schemas/people-bodies'; + +@ApiTags('People') +@Controller({ path: 'people', 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 PeopleController { + constructor(private readonly peopleService: PeopleService) {} + + @Get() + @ApiOperation(PEOPLE_OPERATIONS.getAllPeople) + @ApiResponse(GET_ALL_PEOPLE_RESPONSES[200]) + @ApiResponse(GET_ALL_PEOPLE_RESPONSES[401]) + @ApiResponse(GET_ALL_PEOPLE_RESPONSES[404]) + @ApiResponse(GET_ALL_PEOPLE_RESPONSES[500]) + async getAllPeople( + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + const people = await this.peopleService.findAllByOrganization(organizationId); + + return { + data: people, + count: people.length, + authType: authContext.authType, + ...(authContext.userId && authContext.userEmail && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), + }; + } + + @Post() + @ApiOperation(PEOPLE_OPERATIONS.createMember) + @ApiBody(PEOPLE_BODIES.createMember) + @ApiResponse(CREATE_MEMBER_RESPONSES[201]) + @ApiResponse(CREATE_MEMBER_RESPONSES[400]) + @ApiResponse(CREATE_MEMBER_RESPONSES[401]) + @ApiResponse(CREATE_MEMBER_RESPONSES[404]) + @ApiResponse(CREATE_MEMBER_RESPONSES[500]) + async createMember( + @Body() createData: CreatePeopleDto, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + const member = await this.peopleService.create(organizationId, createData); + + return { + ...member, + authType: authContext.authType, + ...(authContext.userId && authContext.userEmail && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), + }; + } + + @Post('bulk') + @ApiOperation(PEOPLE_OPERATIONS.bulkCreateMembers) + @ApiBody(PEOPLE_BODIES.bulkCreateMembers) + @ApiResponse(BULK_CREATE_MEMBERS_RESPONSES[201]) + @ApiResponse(BULK_CREATE_MEMBERS_RESPONSES[400]) + @ApiResponse(BULK_CREATE_MEMBERS_RESPONSES[401]) + @ApiResponse(BULK_CREATE_MEMBERS_RESPONSES[404]) + @ApiResponse(BULK_CREATE_MEMBERS_RESPONSES[500]) + async bulkCreateMembers( + @Body() bulkCreateData: BulkCreatePeopleDto, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + const result = await this.peopleService.bulkCreate(organizationId, bulkCreateData); + + return { + ...result, + authType: authContext.authType, + ...(authContext.userId && authContext.userEmail && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), + }; + } + + @Get(':id') + @ApiOperation(PEOPLE_OPERATIONS.getPersonById) + @ApiParam(PEOPLE_PARAMS.memberId) + @ApiResponse(GET_PERSON_BY_ID_RESPONSES[200]) + @ApiResponse(GET_PERSON_BY_ID_RESPONSES[401]) + @ApiResponse(GET_PERSON_BY_ID_RESPONSES[404]) + @ApiResponse(GET_PERSON_BY_ID_RESPONSES[500]) + async getPersonById( + @Param('id') memberId: string, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + const person = await this.peopleService.findById(memberId, organizationId); + + return { + ...person, + authType: authContext.authType, + ...(authContext.userId && authContext.userEmail && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), + }; + } + + @Patch(':id') + @ApiOperation(PEOPLE_OPERATIONS.updateMember) + @ApiParam(PEOPLE_PARAMS.memberId) + @ApiBody(PEOPLE_BODIES.updateMember) + @ApiResponse(UPDATE_MEMBER_RESPONSES[200]) + @ApiResponse(UPDATE_MEMBER_RESPONSES[400]) + @ApiResponse(UPDATE_MEMBER_RESPONSES[401]) + @ApiResponse(UPDATE_MEMBER_RESPONSES[404]) + @ApiResponse(UPDATE_MEMBER_RESPONSES[500]) + async updateMember( + @Param('id') memberId: string, + @Body() updateData: UpdatePeopleDto, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + const updatedMember = await this.peopleService.updateById( + memberId, + organizationId, + updateData, + ); + + return { + ...updatedMember, + authType: authContext.authType, + ...(authContext.userId && authContext.userEmail && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), + }; + } + + @Delete(':id') + @ApiOperation(PEOPLE_OPERATIONS.deleteMember) + @ApiParam(PEOPLE_PARAMS.memberId) + @ApiResponse(DELETE_MEMBER_RESPONSES[200]) + @ApiResponse(DELETE_MEMBER_RESPONSES[401]) + @ApiResponse(DELETE_MEMBER_RESPONSES[404]) + @ApiResponse(DELETE_MEMBER_RESPONSES[500]) + async deleteMember( + @Param('id') memberId: string, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + ) { + const result = await this.peopleService.deleteById(memberId, organizationId); + + return { + ...result, + authType: authContext.authType, + ...(authContext.userId && authContext.userEmail && { + authenticatedUser: { + id: authContext.userId, + email: authContext.userEmail, + }, + }), + }; + } +} diff --git a/apps/api/src/people/people.module.ts b/apps/api/src/people/people.module.ts new file mode 100644 index 000000000..b6ecf5cda --- /dev/null +++ b/apps/api/src/people/people.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { PeopleController } from './people.controller'; +import { PeopleService } from './people.service'; + +@Module({ + imports: [AuthModule], + controllers: [PeopleController], + providers: [PeopleService], + exports: [PeopleService], +}) +export class PeopleModule {} diff --git a/apps/api/src/people/people.service.ts b/apps/api/src/people/people.service.ts new file mode 100644 index 000000000..ad02d02a9 --- /dev/null +++ b/apps/api/src/people/people.service.ts @@ -0,0 +1,178 @@ +import { Injectable, NotFoundException, Logger, BadRequestException } from '@nestjs/common'; +import type { PeopleResponseDto } from './dto/people-responses.dto'; +import type { CreatePeopleDto } from './dto/create-people.dto'; +import type { UpdatePeopleDto } from './dto/update-people.dto'; +import type { BulkCreatePeopleDto } from './dto/bulk-create-people.dto'; +import { MemberValidator } from './utils/member-validator'; +import { MemberQueries } from './utils/member-queries'; + +@Injectable() +export class PeopleService { + private readonly logger = new Logger(PeopleService.name); + + async findAllByOrganization(organizationId: string): Promise { + try { + await MemberValidator.validateOrganization(organizationId); + const members = await MemberQueries.findAllByOrganization(organizationId); + + this.logger.log(`Retrieved ${members.length} members for organization ${organizationId}`); + return members; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + this.logger.error(`Failed to retrieve members for organization ${organizationId}:`, error); + throw new Error(`Failed to retrieve members: ${error.message}`); + } + } + + async findById(memberId: string, organizationId: string): Promise { + try { + await MemberValidator.validateOrganization(organizationId); + const member = await MemberQueries.findByIdInOrganization(memberId, organizationId); + + if (!member) { + throw new NotFoundException(`Member with ID ${memberId} not found in organization ${organizationId}`); + } + + this.logger.log(`Retrieved member: ${member.user.name} (${memberId})`); + return member; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + this.logger.error(`Failed to retrieve member ${memberId} in organization ${organizationId}:`, error); + throw new Error(`Failed to retrieve member: ${error.message}`); + } + } + + async create(organizationId: string, createData: CreatePeopleDto): Promise { + try { + await MemberValidator.validateOrganization(organizationId); + await MemberValidator.validateUser(createData.userId); + await MemberValidator.validateUserNotMember(createData.userId, organizationId); + + const member = await MemberQueries.createMember(organizationId, createData); + + this.logger.log(`Created member: ${member.user.name} (${member.id}) for organization ${organizationId}`); + return member; + } catch (error) { + if (error instanceof NotFoundException || error instanceof BadRequestException) { + throw error; + } + this.logger.error(`Failed to create member for organization ${organizationId}:`, error); + throw new Error(`Failed to create member: ${error.message}`); + } + } + + async bulkCreate(organizationId: string, bulkCreateData: BulkCreatePeopleDto): Promise<{ + created: PeopleResponseDto[]; + errors: Array<{ index: number; userId: string; error: string }>; + summary: { total: number; successful: number; failed: number }; + }> { + try { + await MemberValidator.validateOrganization(organizationId); + + const created: PeopleResponseDto[] = []; + const errors: Array<{ index: number; userId: string; error: string }> = []; + + // Process each member in the bulk request + // Validate all users and membership status first, collecting errors + const validMembers: CreatePeopleDto[] = []; + for (let i = 0; i < bulkCreateData.members.length; i++) { + const memberData = bulkCreateData.members[i]; + try { + await MemberValidator.validateUser(memberData.userId); + await MemberValidator.validateUserNotMember(memberData.userId, organizationId); + validMembers.push(memberData); + } catch (error) { + errors.push({ + index: i, + userId: memberData.userId, + error: error.message || 'Unknown error occurred', + }); + this.logger.error(`Failed to validate member at index ${i} (userId: ${memberData.userId}):`, error); + } + } + + // Bulk insert valid members using createMany + if (validMembers.length > 0) { + const createdMembers = await MemberQueries.bulkCreateMembers(organizationId, validMembers); + + created.push(...createdMembers); + createdMembers.forEach(member => { + this.logger.log(`Created member: ${member.user.name} (${member.id}) for organization ${organizationId}`); + }); + } + + const summary = { + total: bulkCreateData.members.length, + successful: created.length, + failed: errors.length, + }; + + this.logger.log(`Bulk create completed for organization ${organizationId}: ${summary.successful}/${summary.total} successful`); + + return { created, errors, summary }; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + this.logger.error(`Failed to bulk create members for organization ${organizationId}:`, error); + throw new Error(`Failed to bulk create members: ${error.message}`); + } + } + + async updateById(memberId: string, organizationId: string, updateData: UpdatePeopleDto): Promise { + try { + await MemberValidator.validateOrganization(organizationId); + const existingMember = await MemberValidator.validateMemberExists(memberId, organizationId); + + // If userId is being updated, validate the new user + if (updateData.userId && updateData.userId !== existingMember.userId) { + await MemberValidator.validateUser(updateData.userId); + await MemberValidator.validateUserNotMember(updateData.userId, organizationId, memberId); + } + + const updatedMember = await MemberQueries.updateMember(memberId, updateData); + + this.logger.log(`Updated member: ${updatedMember.user.name} (${memberId})`); + return updatedMember; + } catch (error) { + if (error instanceof NotFoundException || error instanceof BadRequestException) { + throw error; + } + this.logger.error(`Failed to update member ${memberId} in organization ${organizationId}:`, error); + throw new Error(`Failed to update member: ${error.message}`); + } + } + + async deleteById(memberId: string, organizationId: string): Promise<{ success: boolean; deletedMember: { id: string; name: string; email: string } }> { + try { + await MemberValidator.validateOrganization(organizationId); + const member = await MemberQueries.findMemberForDeletion(memberId, organizationId); + + if (!member) { + throw new NotFoundException(`Member with ID ${memberId} not found in organization ${organizationId}`); + } + + await MemberQueries.deleteMember(memberId); + + this.logger.log(`Deleted member: ${member.user.name} (${memberId}) from organization ${organizationId}`); + return { + success: true, + deletedMember: { + id: member.id, + name: member.user.name, + email: member.user.email, + } + }; + } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + this.logger.error(`Failed to delete member ${memberId} from organization ${organizationId}:`, error); + throw new Error(`Failed to delete member: ${error.message}`); + } + } +} diff --git a/apps/api/src/people/schemas/bulk-create-members.responses.ts b/apps/api/src/people/schemas/bulk-create-members.responses.ts new file mode 100644 index 000000000..1e20a1904 --- /dev/null +++ b/apps/api/src/people/schemas/bulk-create-members.responses.ts @@ -0,0 +1,121 @@ +import { ApiResponseOptions } from '@nestjs/swagger'; + +export const BULK_CREATE_MEMBERS_RESPONSES: Record = { + 201: { + status: 201, + description: 'Bulk member creation completed', + schema: { + type: 'object', + properties: { + created: { + type: 'array', + items: { $ref: '#/components/schemas/PeopleResponseDto' }, + description: 'Successfully created members', + }, + errors: { + type: 'array', + items: { + type: 'object', + properties: { + index: { + type: 'number', + description: 'Index in the original array where the error occurred', + example: 2, + }, + userId: { + type: 'string', + description: 'User ID that failed to be added', + example: 'usr_abc123def456', + }, + error: { + type: 'string', + description: 'Error message explaining why the member could not be created', + example: 'User user@example.com is already a member of this organization', + }, + }, + }, + description: 'Members that failed to be created with error details', + }, + summary: { + type: 'object', + properties: { + total: { + type: 'number', + description: 'Total number of members in the request', + example: 5, + }, + successful: { + type: 'number', + description: 'Number of members successfully created', + example: 3, + }, + failed: { + type: 'number', + description: 'Number of members that failed to be created', + example: 2, + }, + }, + }, + authType: { + type: 'string', + enum: ['api-key', 'session'], + description: 'How the request was authenticated', + }, + authenticatedUser: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'User ID', + example: 'usr_abc123def456', + }, + email: { + type: 'string', + description: 'User email', + example: 'user@company.com', + }, + }, + }, + }, + }, + }, + 400: { + status: 400, + description: 'Bad Request - Invalid bulk data or validation errors', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + examples: [ + 'Validation failed', + 'Members array cannot be empty', + 'Maximum 100 members allowed per bulk request', + ], + }, + }, + }, + }, + 401: { + status: 401, + description: + 'Unauthorized - Invalid authentication or insufficient permissions', + }, + 404: { + status: 404, + description: 'Organization not found', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + example: 'Organization with ID org_abc123def456 not found', + }, + }, + }, + }, + 500: { + status: 500, + description: 'Internal server error', + }, +}; diff --git a/apps/api/src/people/schemas/create-member.responses.ts b/apps/api/src/people/schemas/create-member.responses.ts new file mode 100644 index 000000000..9059201b8 --- /dev/null +++ b/apps/api/src/people/schemas/create-member.responses.ts @@ -0,0 +1,51 @@ +import { ApiResponseOptions } from '@nestjs/swagger'; + +export const CREATE_MEMBER_RESPONSES: Record = { + 201: { + status: 201, + description: 'Member created successfully', + type: 'PeopleResponseDto', + }, + 400: { + status: 400, + description: 'Bad Request - Invalid member data or user already exists', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + examples: [ + 'Validation failed', + 'User user@example.com is already a member of this organization', + 'Invalid user ID or role', + ], + }, + }, + }, + }, + 401: { + status: 401, + description: + 'Unauthorized - Invalid authentication or insufficient permissions', + }, + 404: { + status: 404, + description: 'Organization or user not found', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + examples: [ + 'Organization with ID org_abc123def456 not found', + 'User with ID usr_abc123def456 not found', + ], + }, + }, + }, + }, + 500: { + status: 500, + description: 'Internal server error', + }, +}; diff --git a/apps/api/src/people/schemas/delete-member.responses.ts b/apps/api/src/people/schemas/delete-member.responses.ts new file mode 100644 index 000000000..efc7b5272 --- /dev/null +++ b/apps/api/src/people/schemas/delete-member.responses.ts @@ -0,0 +1,68 @@ +import { ApiResponseOptions } from '@nestjs/swagger'; + +export const DELETE_MEMBER_RESPONSES: Record = { + 200: { + status: 200, + description: 'Member deleted successfully', + schema: { + type: 'object', + properties: { + success: { + type: 'boolean', + description: 'Indicates successful deletion', + example: true, + }, + deletedMember: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'The deleted member ID', + example: 'mem_abc123def456', + }, + name: { + type: 'string', + description: 'The deleted member name', + example: 'John Doe', + }, + email: { + type: 'string', + description: 'The deleted member email', + example: 'john.doe@company.com', + }, + }, + }, + authType: { + type: 'string', + enum: ['api-key', 'session'], + description: 'How the request was authenticated', + }, + }, + }, + }, + 401: { + status: 401, + description: + 'Unauthorized - Invalid authentication or insufficient permissions', + }, + 404: { + status: 404, + description: 'Organization or member not found', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + examples: [ + 'Organization with ID org_abc123def456 not found', + 'Member with ID mem_abc123def456 not found in organization org_abc123def456', + ], + }, + }, + }, + }, + 500: { + status: 500, + description: 'Internal server error', + }, +}; diff --git a/apps/api/src/people/schemas/get-all-people.responses.ts b/apps/api/src/people/schemas/get-all-people.responses.ts new file mode 100644 index 000000000..a82491b1b --- /dev/null +++ b/apps/api/src/people/schemas/get-all-people.responses.ts @@ -0,0 +1,87 @@ +import { ApiResponseOptions } from '@nestjs/swagger'; + +export const GET_ALL_PEOPLE_RESPONSES: Record = { + 200: { + status: 200, + description: 'People retrieved successfully', + schema: { + type: 'object', + properties: { + data: { + type: 'array', + items: { $ref: '#/components/schemas/PeopleResponseDto' }, + }, + count: { + type: 'number', + description: 'Total number of people', + example: 25, + }, + authType: { + type: 'string', + enum: ['api-key', 'session'], + description: 'How the request was authenticated', + }, + authenticatedUser: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'User ID', + example: 'usr_abc123def456', + }, + email: { + type: 'string', + description: 'User email', + example: 'user@company.com', + }, + }, + }, + }, + }, + }, + 401: { + status: 401, + description: + 'Unauthorized - Invalid authentication or insufficient permissions', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + examples: [ + 'Invalid or expired API key', + 'Invalid or expired session', + 'User does not have access to organization', + 'Organization context required', + ], + }, + }, + }, + }, + 404: { + status: 404, + description: 'Organization not found', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + example: 'Organization with ID org_abc123def456 not found', + }, + }, + }, + }, + 500: { + status: 500, + description: 'Internal server error', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + example: 'Failed to retrieve members', + }, + }, + }, + }, +}; diff --git a/apps/api/src/people/schemas/get-person-by-id.responses.ts b/apps/api/src/people/schemas/get-person-by-id.responses.ts new file mode 100644 index 000000000..aa8be75b6 --- /dev/null +++ b/apps/api/src/people/schemas/get-person-by-id.responses.ts @@ -0,0 +1,34 @@ +import { ApiResponseOptions } from '@nestjs/swagger'; + +export const GET_PERSON_BY_ID_RESPONSES: Record = { + 200: { + status: 200, + description: 'Person retrieved successfully', + type: 'PeopleResponseDto', + }, + 401: { + status: 401, + description: + 'Unauthorized - Invalid authentication or insufficient permissions', + }, + 404: { + status: 404, + description: 'Organization or member not found', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + examples: [ + 'Organization with ID org_abc123def456 not found', + 'Member with ID mem_abc123def456 not found in organization org_abc123def456', + ], + }, + }, + }, + }, + 500: { + status: 500, + description: 'Internal server error', + }, +}; diff --git a/apps/api/src/people/schemas/people-bodies.ts b/apps/api/src/people/schemas/people-bodies.ts new file mode 100644 index 000000000..20947fc50 --- /dev/null +++ b/apps/api/src/people/schemas/people-bodies.ts @@ -0,0 +1,19 @@ +import type { ApiBodyOptions } from '@nestjs/swagger'; +import { CreatePeopleDto } from '../dto/create-people.dto'; +import { UpdatePeopleDto } from '../dto/update-people.dto'; +import { BulkCreatePeopleDto } from '../dto/bulk-create-people.dto'; + +export const PEOPLE_BODIES: Record = { + createMember: { + description: 'Member creation data', + type: CreatePeopleDto, + }, + bulkCreateMembers: { + description: 'Bulk member creation data', + type: BulkCreatePeopleDto, + }, + updateMember: { + description: 'Member update data', + type: UpdatePeopleDto, + }, +}; diff --git a/apps/api/src/people/schemas/people-operations.ts b/apps/api/src/people/schemas/people-operations.ts new file mode 100644 index 000000000..3ce0c2cfe --- /dev/null +++ b/apps/api/src/people/schemas/people-operations.ts @@ -0,0 +1,34 @@ +import type { ApiOperationOptions } from '@nestjs/swagger'; + +export const PEOPLE_OPERATIONS: Record = { + getAllPeople: { + summary: 'Get all people', + description: + 'Returns all members for the authenticated organization with their user information. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).', + }, + createMember: { + summary: 'Create a new member', + description: + 'Adds a new member to the authenticated organization. The user must already exist in the system. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).', + }, + bulkCreateMembers: { + summary: 'Add multiple members to organization', + description: + 'Bulk adds multiple members to the authenticated organization. Each member must have a valid user ID that exists in the system. Members who already exist in the organization or have invalid data will be skipped with error details returned. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).', + }, + getPersonById: { + summary: 'Get person by ID', + description: + 'Returns a specific member by ID for the authenticated organization with their user information. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).', + }, + updateMember: { + summary: 'Update member', + description: + 'Partially updates a member. Only provided fields will be updated. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).', + }, + deleteMember: { + summary: 'Delete member', + description: + 'Permanently removes a member from the organization. This action cannot be undone. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).', + }, +}; diff --git a/apps/api/src/people/schemas/people-params.ts b/apps/api/src/people/schemas/people-params.ts new file mode 100644 index 000000000..5c8124d46 --- /dev/null +++ b/apps/api/src/people/schemas/people-params.ts @@ -0,0 +1,9 @@ +import type { ApiParamOptions } from '@nestjs/swagger'; + +export const PEOPLE_PARAMS: Record = { + memberId: { + name: 'id', + description: 'Member ID', + example: 'mem_abc123def456', + }, +}; diff --git a/apps/api/src/people/schemas/update-member.responses.ts b/apps/api/src/people/schemas/update-member.responses.ts new file mode 100644 index 000000000..60a0de06d --- /dev/null +++ b/apps/api/src/people/schemas/update-member.responses.ts @@ -0,0 +1,52 @@ +import { ApiResponseOptions } from '@nestjs/swagger'; + +export const UPDATE_MEMBER_RESPONSES: Record = { + 200: { + status: 200, + description: 'Member updated successfully', + type: 'PeopleResponseDto', + }, + 400: { + status: 400, + description: 'Bad Request - Invalid update data or user conflict', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + examples: [ + 'Validation failed', + 'User user@example.com is already a member of this organization', + 'Invalid user ID or role', + ], + }, + }, + }, + }, + 401: { + status: 401, + description: + 'Unauthorized - Invalid authentication or insufficient permissions', + }, + 404: { + status: 404, + description: 'Organization, member, or user not found', + schema: { + type: 'object', + properties: { + message: { + type: 'string', + examples: [ + 'Organization with ID org_abc123def456 not found', + 'Member with ID mem_abc123def456 not found in organization org_abc123def456', + 'User with ID usr_abc123def456 not found', + ], + }, + }, + }, + }, + 500: { + status: 500, + description: 'Internal server error', + }, +}; diff --git a/apps/api/src/people/utils/member-queries.ts b/apps/api/src/people/utils/member-queries.ts new file mode 100644 index 000000000..8cb9e6fb3 --- /dev/null +++ b/apps/api/src/people/utils/member-queries.ts @@ -0,0 +1,160 @@ +import { db } from '@trycompai/db'; +import type { PeopleResponseDto } from '../dto/people-responses.dto'; +import type { CreatePeopleDto } from '../dto/create-people.dto'; +import type { UpdatePeopleDto } from '../dto/update-people.dto'; + +/** + * Common database queries for member operations + */ +export class MemberQueries { + /** + * Standard member selection fields + */ + static readonly MEMBER_SELECT = { + id: true, + organizationId: true, + userId: true, + role: true, + createdAt: true, + department: true, + isActive: true, + fleetDmLabelId: true, + user: { + select: { + id: true, + name: true, + email: true, + emailVerified: true, + image: true, + createdAt: true, + updatedAt: true, + lastLogin: true, + }, + }, + } as const; + + /** + * Get all members for an organization + */ + static async findAllByOrganization(organizationId: string): Promise { + return db.member.findMany({ + where: { organizationId }, + select: this.MEMBER_SELECT, + orderBy: { createdAt: 'desc' }, + }); + } + + /** + * Find a member by ID within an organization + */ + static async findByIdInOrganization(memberId: string, organizationId: string): Promise { + return db.member.findFirst({ + where: { + id: memberId, + organizationId, + }, + select: this.MEMBER_SELECT, + }); + } + + /** + * Create a new member + */ + static async createMember(organizationId: string, createData: CreatePeopleDto): Promise { + return db.member.create({ + data: { + organizationId, + userId: createData.userId, + role: createData.role, + department: createData.department || 'none', + isActive: createData.isActive ?? true, + fleetDmLabelId: createData.fleetDmLabelId || null, + }, + select: this.MEMBER_SELECT, + }); + } + + /** + * Update a member by ID + */ + static async updateMember(memberId: string, updateData: UpdatePeopleDto): Promise { + // Prepare update data with defaults for optional fields + const updatePayload: any = { ...updateData }; + + // Handle fleetDmLabelId: convert undefined to null for database + if (updateData.fleetDmLabelId === undefined && 'fleetDmLabelId' in updateData) { + updatePayload.fleetDmLabelId = null; + } + + return db.member.update({ + where: { id: memberId }, + data: updatePayload, + select: this.MEMBER_SELECT, + }); + } + + /** + * Get member for deletion (with minimal user info) + */ + static async findMemberForDeletion(memberId: string, organizationId: string): Promise<{ + id: string; + user: { id: string; name: string; email: string }; + } | null> { + return db.member.findFirst({ + where: { + id: memberId, + organizationId, + }, + select: { + id: true, + user: { + select: { + id: true, + name: true, + email: true, + }, + }, + }, + }); + } + + /** + * Delete a member by ID + */ + static async deleteMember(memberId: string): Promise { + await db.member.delete({ + where: { id: memberId }, + }); + } + + /** + * Bulk create members for an organization + */ + static async bulkCreateMembers(organizationId: string, memberData: CreatePeopleDto[]): Promise { + // Prepare data for createMany + const data = memberData.map(member => ({ + organizationId, + userId: member.userId, + role: member.role, + department: member.department || 'none', + isActive: member.isActive ?? true, + fleetDmLabelId: member.fleetDmLabelId || null, + })); + + // Perform bulk insert + await db.member.createMany({ + data, + skipDuplicates: true, // Prevents error if userId is already a member + }); + + // Fetch the created members for response (by userId, since ids are generated) + return db.member.findMany({ + where: { + organizationId, + userId: { in: memberData.map(m => m.userId) }, + }, + select: this.MEMBER_SELECT, + orderBy: { createdAt: 'desc' }, + }); + } +} diff --git a/apps/api/src/people/utils/member-validator.ts b/apps/api/src/people/utils/member-validator.ts new file mode 100644 index 000000000..bbb4a5402 --- /dev/null +++ b/apps/api/src/people/utils/member-validator.ts @@ -0,0 +1,80 @@ +import { NotFoundException, BadRequestException } from '@nestjs/common'; +import { db } from '@trycompai/db'; + +export class MemberValidator { + /** + * Validates that an organization exists + */ + static async validateOrganization(organizationId: string): Promise { + const organization = await db.organization.findUnique({ + where: { id: organizationId }, + select: { id: true, name: true }, + }); + + if (!organization) { + throw new NotFoundException(`Organization with ID ${organizationId} not found`); + } + } + + /** + * Validates that a user exists and returns user data + */ + static async validateUser(userId: string): Promise<{ id: string; name: string; email: string }> { + const user = await db.user.findUnique({ + where: { id: userId }, + select: { id: true, name: true, email: true }, + }); + + if (!user) { + throw new NotFoundException(`User with ID ${userId} not found`); + } + + return user; + } + + /** + * Validates that a member exists in an organization + */ + static async validateMemberExists(memberId: string, organizationId: string): Promise<{ id: string; userId: string }> { + const member = await db.member.findFirst({ + where: { + id: memberId, + organizationId, + }, + select: { id: true, userId: true }, + }); + + if (!member) { + throw new NotFoundException(`Member with ID ${memberId} not found in organization ${organizationId}`); + } + + return member; + } + + /** + * Validates that a user is not already a member of an organization + */ + static async validateUserNotMember( + userId: string, + organizationId: string, + excludeMemberId?: string + ): Promise { + const whereClause: any = { + userId, + organizationId, + }; + + if (excludeMemberId) { + whereClause.id = { not: excludeMemberId }; + } + + const existingMember = await db.member.findFirst({ + where: whereClause, + }); + + if (existingMember) { + const user = await this.validateUser(userId); + throw new BadRequestException(`User ${user.email} is already a member of this organization`); + } + } +}