diff --git a/.github/workflows/database-migrations-main.yml b/.github/workflows/database-migrations-main.yml index d0265c44aa..5a447e962d 100644 --- a/.github/workflows/database-migrations-main.yml +++ b/.github/workflows/database-migrations-main.yml @@ -16,4 +16,4 @@ jobs: DATABASE_URL: ${{ secrets.DATABASE_URL_DEV }} run: | cd packages/db - npx prisma migrate deploy \ No newline at end of file + npx prisma migrate deploy diff --git a/.github/workflows/database-migrations-release.yml b/.github/workflows/database-migrations-release.yml index 19ff5c5d3a..14c98d32b0 100644 --- a/.github/workflows/database-migrations-release.yml +++ b/.github/workflows/database-migrations-release.yml @@ -16,4 +16,4 @@ jobs: DATABASE_URL: ${{ secrets.DATABASE_URL_PROD }} run: | cd packages/db - npx prisma migrate deploy \ No newline at end of file + npx prisma migrate deploy diff --git a/.github/workflows/github-releases-to-discord.yml b/.github/workflows/github-releases-to-discord.yml index 46d331bbc1..294dfbc859 100644 --- a/.github/workflows/github-releases-to-discord.yml +++ b/.github/workflows/github-releases-to-discord.yml @@ -5,7 +5,7 @@ on: jobs: github-releases-to-discord: permissions: - contents: read + contents: read runs-on: ubuntu-latest steps: - name: Checkout @@ -13,4 +13,4 @@ jobs: - name: Github Releases To Discord uses: SethCohen/github-releases-to-discord@v1.16.2 with: - webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }} \ No newline at end of file + webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 00e8f77f0b..ea6d42b8e4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' # Or your preferred Node.js version + node-version: "20" # Or your preferred Node.js version - name: Setup Bun uses: oven-sh/setup-bun@v2 @@ -43,4 +43,4 @@ jobs: GITHUB_TOKEN: ${{ secrets.GH_TOKEN || secrets.GITHUB_TOKEN }} # NPM_TOKEN: ${{ secrets.NPM_TOKEN }} # Uncomment if publishing to npm DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} - run: npx semantic-release \ No newline at end of file + run: npx semantic-release diff --git a/.github/workflows/trigger-tasks-deploy-main.yml b/.github/workflows/trigger-tasks-deploy-main.yml index a390ebcecf..e125f1d5b9 100644 --- a/.github/workflows/trigger-tasks-deploy-main.yml +++ b/.github/workflows/trigger-tasks-deploy-main.yml @@ -18,7 +18,7 @@ jobs: run: rm -rf node_modules .bun - name: Install dependencies run: | - bun install --frozen-lockfile + bun install - name: Generate Prisma client working-directory: ./packages/db run: bunx prisma generate diff --git a/.github/workflows/trigger-tasks-deploy-release.yml b/.github/workflows/trigger-tasks-deploy-release.yml index 9b45794859..ba0c33251e 100644 --- a/.github/workflows/trigger-tasks-deploy-release.yml +++ b/.github/workflows/trigger-tasks-deploy-release.yml @@ -20,7 +20,7 @@ jobs: uses: oven-sh/setup-bun@v2 - name: Install dependencies - run: bun install --frozen-lockfile + run: bun install - name: Generate Prisma client working-directory: ./packages/db diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000000..e847d81ad1 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,12 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +# Check for dependency mismatches +echo "Checking for dependency mismatches..." +bun run deps:check + +# If there are mismatches, offer to fix them +if [ $? -ne 0 ]; then + echo "Dependency mismatches found! Run 'bun run deps:fix' to fix them." + exit 1 +fi \ No newline at end of file diff --git a/.husky/pre-push b/.husky/pre-push old mode 100644 new mode 100755 index d327b4569b..b63a85dfc2 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,3 +1,6 @@ +#!/bin/sh + +# Check branch name convention branch_name=$(git symbolic-ref --short HEAD) pattern="^[a-zA-Z-]+/.+$" @@ -5,4 +8,14 @@ if [[ ! $branch_name =~ $pattern ]]; then echo "Branch name '$branch_name' does not follow the naming convention." echo "Branch names should follow the pattern: category/branch-name (e.g. feature/add-button)" exit 1 -fi \ No newline at end of file +fi + +echo "Running lint and typecheck before push..." + +# Run lint +bun run lint + +# Run typecheck +bun run typecheck:ci + +echo "βœ… All checks passed!" \ No newline at end of file diff --git a/.pdf/convert_policy_to_pdf.ipynb b/.pdf/convert_policy_to_pdf.ipynb new file mode 100644 index 0000000000..99f989af39 --- /dev/null +++ b/.pdf/convert_policy_to_pdf.ipynb @@ -0,0 +1,1670 @@ +{ + "cells": [ + { + "cell_type": "raw", + "metadata": { + "vscode": { + "languageId": "raw" + } + }, + "source": [ + "# Policy JSON to PDF Converter\n", + "\n", + "This notebook converts TipTap/ProseMirror JSON format policy documents into clean, professional PDFs using ReportLab.\n", + "\n", + "## Features:\n", + "- Pure Python solution (no system dependencies required)\n", + "- Loads policies from `source_data.txt` file automatically\n", + "- Professional formatting with custom styles\n", + "- Supports tables, headings, paragraphs, ordered lists, and bullet lists\n", + "- Template variable replacement ({{organization}}, {{date}})\n", + "- Batch processing to generate all policies at once\n", + "- Auto-generates filenames from document titles\n", + "\n", + "## How to use:\n", + "1. Run the first cell to install ReportLab\n", + "2. Run the function definition cells\n", + "3. Run the data loading cell (loads from source_data.txt)\n", + "4. Configure your options and run the generation cell\n", + "5. Your PDF will be generated in the `pdfs/` directory\n", + "\n", + "## Batch Processing:\n", + "- Use the batch processing cell at the end to generate PDFs for all policies at once\n" + ] + }, + { + "cell_type": "code", + "execution_count": 457, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Defaulting to user installation because normal site-packages is not writeable\n", + "Requirement already satisfied: reportlab in /Users/claudfuen/Library/Python/3.9/lib/python/site-packages (4.4.1)\n", + "Requirement already satisfied: pillow>=9.0.0 in /Users/claudfuen/Library/Python/3.9/lib/python/site-packages (from reportlab) (11.2.1)\n", + "Requirement already satisfied: chardet in /Users/claudfuen/Library/Python/3.9/lib/python/site-packages (from reportlab) (5.2.0)\n", + "\u001b[33mWARNING: You are using pip version 21.2.4; however, version 25.1.1 is available.\n", + "You should consider upgrading via the '/Applications/Xcode.app/Contents/Developer/usr/bin/python3 -m pip install --upgrade pip' command.\u001b[0m\n", + "Note: you may need to restart the kernel to use updated packages.\n" + ] + } + ], + "source": [ + "# Install required packages\n", + "%pip install reportlab\n", + "\n", + "# Import required libraries\n", + "import json\n", + "from datetime import datetime\n", + "import os\n", + "import re\n", + "from reportlab.lib import colors\n", + "from reportlab.lib.pagesizes import letter, A4\n", + "from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, PageBreak\n", + "from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle\n", + "from reportlab.lib.units import inch\n", + "from reportlab.lib.enums import TA_JUSTIFY, TA_LEFT, TA_CENTER\n", + "from reportlab.pdfbase import pdfmetrics\n", + "from reportlab.pdfbase.ttfonts import TTFont\n" + ] + }, + { + "cell_type": "code", + "execution_count": 458, + "metadata": {}, + "outputs": [], + "source": [ + "def parse_json_data(json_data):\n", + " \"\"\"Parse the JSON data and convert to a list of nodes\"\"\"\n", + " \n", + " # If it's already a set or dict-like object containing JSON strings\n", + " if isinstance(json_data, (set, dict)):\n", + " json_data = list(json_data) # Convert to list\n", + " json_data = [json.loads(item) for item in json_data] # Parse each JSON string\n", + " \n", + " # If it's a string representation of a set\n", + " elif isinstance(json_data, str):\n", + " if json_data.startswith(\"{\") and json_data.endswith(\"}\"):\n", + " # It's a string representation of a set containing JSON strings\n", + " json_data = eval(json_data) # Convert string representation to actual set\n", + " json_data = list(json_data) # Convert set to list\n", + " json_data = [json.loads(item) for item in json_data] # Parse each JSON string\n", + " \n", + " # If it's already a list, check if items need parsing\n", + " elif isinstance(json_data, list):\n", + " # Check if first item is a string that needs parsing\n", + " if json_data and isinstance(json_data[0], str):\n", + " json_data = [json.loads(item) for item in json_data]\n", + " \n", + " return json_data\n", + "\n", + "def create_custom_styles():\n", + " \"\"\"Create custom paragraph styles for the PDF\"\"\"\n", + " styles = getSampleStyleSheet()\n", + " \n", + " # Custom title style - compact\n", + " styles.add(ParagraphStyle(\n", + " name='CustomTitle',\n", + " parent=styles['Heading1'],\n", + " fontSize=20,\n", + " textColor=colors.HexColor('#000000'),\n", + " spaceAfter=10,\n", + " spaceBefore=0,\n", + " alignment=TA_LEFT,\n", + " leading=24,\n", + " fontName='Helvetica-Bold'\n", + " ))\n", + " \n", + " # Custom heading styles - tighter spacing\n", + " styles.add(ParagraphStyle(\n", + " name='CustomHeading2',\n", + " parent=styles['Heading2'],\n", + " fontSize=16,\n", + " textColor=colors.HexColor('#1a1a1a'),\n", + " spaceBefore=12,\n", + " spaceAfter=6,\n", + " alignment=TA_LEFT,\n", + " fontName='Helvetica-Bold'\n", + " ))\n", + " \n", + " styles.add(ParagraphStyle(\n", + " name='CustomHeading3',\n", + " parent=styles['Heading3'],\n", + " fontSize=13,\n", + " textColor=colors.HexColor('#2c3e50'),\n", + " spaceBefore=10,\n", + " spaceAfter=4,\n", + " alignment=TA_LEFT,\n", + " fontName='Helvetica-Bold'\n", + " ))\n", + " \n", + " # Custom body text - minimal spacing\n", + " styles.add(ParagraphStyle(\n", + " name='CustomBodyText',\n", + " parent=styles['BodyText'],\n", + " fontSize=10,\n", + " alignment=TA_LEFT,\n", + " spaceBefore=2,\n", + " spaceAfter=6,\n", + " leading=14,\n", + " textColor=colors.HexColor('#333333')\n", + " ))\n", + " \n", + " # List item style - compact\n", + " styles.add(ParagraphStyle(\n", + " name='ListItem',\n", + " parent=styles['BodyText'],\n", + " fontSize=10,\n", + " leftIndent=20,\n", + " spaceBefore=1,\n", + " spaceAfter=1,\n", + " alignment=TA_LEFT,\n", + " leading=13,\n", + " textColor=colors.HexColor('#333333')\n", + " ))\n", + " \n", + " return styles\n" + ] + }, + { + "cell_type": "code", + "execution_count": 459, + "metadata": {}, + "outputs": [], + "source": [ + "def process_content(content):\n", + " \"\"\"Process content array and return text\"\"\"\n", + " if not content:\n", + " return \"\"\n", + " \n", + " text_parts = []\n", + " for item in content:\n", + " if item.get(\"type\") == \"text\":\n", + " text_parts.append(item.get(\"text\", \"\"))\n", + " return \"\".join(text_parts)\n", + "\n", + "def generate_pdf_with_reportlab(json_data, output_filename=None, organization_name=None, custom_date=None, policy_name=None, policy_description=None):\n", + " \"\"\"\n", + " Generate PDF from JSON data using ReportLab\n", + " \n", + " Args:\n", + " json_data: The JSON data (as string or parsed object)\n", + " output_filename: Name of the output PDF file (optional - will use title from JSON if not provided)\n", + " organization_name: Custom organization name (optional)\n", + " custom_date: Custom date (optional)\n", + " policy_name: Name of the policy to display at the top (optional)\n", + " policy_description: Description of the policy to display (optional)\n", + " \"\"\"\n", + " # Parse JSON data\n", + " nodes = parse_json_data(json_data)\n", + " \n", + " # Extract title from first heading if no filename provided\n", + " auto_generated_filename = False\n", + " if output_filename is None:\n", + " # Find the first heading in the document\n", + " title = None\n", + " for node in nodes:\n", + " if node.get(\"type\") == \"heading\":\n", + " content = node.get(\"content\", [])\n", + " title = process_content(content)\n", + " break\n", + " \n", + " if title:\n", + " # Convert title to filename-friendly format\n", + " # Remove special characters and replace spaces with underscores\n", + " filename_base = re.sub(r'[^\\w\\s-]', '', title.lower())\n", + " filename_base = re.sub(r'[-\\s]+', '_', filename_base)\n", + " output_filename = f\"{filename_base}.pdf\"\n", + " auto_generated_filename = True\n", + " else:\n", + " output_filename = \"output.pdf\"\n", + " \n", + " # Set up organization and date\n", + " org_name = organization_name or \"Your Organization\"\n", + " date_str = custom_date or datetime.now().strftime(\"%Y-%m-%d\")\n", + " \n", + " # Create pdfs directory if it doesn't exist\n", + " pdf_dir = \"pdfs\"\n", + " if not os.path.exists(pdf_dir):\n", + " os.makedirs(pdf_dir)\n", + " print(f\"πŸ“ Created directory: {pdf_dir}/\")\n", + " \n", + " # Prepend the pdfs directory to the filename\n", + " full_output_path = os.path.join(pdf_dir, output_filename)\n", + " \n", + " # Create the PDF document\n", + " doc = SimpleDocTemplate(full_output_path, pagesize=letter,\n", + " rightMargin=72, leftMargin=72,\n", + " topMargin=72, bottomMargin=72)\n", + " \n", + " # Get custom styles\n", + " styles = create_custom_styles()\n", + " \n", + " # Container for the 'Flowable' objects\n", + " elements = []\n", + " \n", + " # Add policy name and description at the top if provided\n", + " if policy_name:\n", + " # Add policy name as a main title\n", + " elements.append(Paragraph(policy_name, styles['CustomTitle']))\n", + " elements.append(Spacer(1, 0.1*inch))\n", + " \n", + " if policy_description:\n", + " # Add policy description\n", + " desc_style = ParagraphStyle(\n", + " name='PolicyDescription',\n", + " parent=styles['BodyText'],\n", + " fontSize=11,\n", + " textColor=colors.HexColor('#555555'),\n", + " alignment=TA_LEFT,\n", + " spaceAfter=10,\n", + " leading=14\n", + " )\n", + " elements.append(Paragraph(policy_description, desc_style))\n", + " elements.append(Spacer(1, 0.15*inch))\n", + " \n", + " # Add a horizontal line divider\n", + " from reportlab.platypus import HRFlowable\n", + " elements.append(HRFlowable(width=\"100%\", thickness=1, color=colors.HexColor('#cccccc')))\n", + " elements.append(Spacer(1, 0.15*inch))\n", + " \n", + " # Process each node\n", + " for node in nodes:\n", + " node_type = node.get(\"type\", \"\")\n", + " attrs = node.get(\"attrs\", {})\n", + " content = node.get(\"content\", [])\n", + " \n", + " if node_type == \"heading\":\n", + " level = attrs.get(\"level\", 1)\n", + " text = process_content(content)\n", + " # Replace template variables\n", + " text = text.replace(\"{{organization}}\", org_name)\n", + " text = text.replace(\"{{date}}\", date_str)\n", + " \n", + " if level == 1:\n", + " elements.append(Paragraph(text, styles['CustomTitle']))\n", + " elements.append(Spacer(1, 0.05*inch))\n", + " elif level == 2:\n", + " elements.append(Paragraph(text, styles['CustomHeading2']))\n", + " elif level == 3:\n", + " elements.append(Paragraph(text, styles['CustomHeading3']))\n", + " else:\n", + " elements.append(Paragraph(text, styles['Heading4']))\n", + " \n", + " elif node_type == \"paragraph\":\n", + " text = process_content(content)\n", + " # Replace template variables\n", + " text = text.replace(\"{{organization}}\", org_name)\n", + " text = text.replace(\"{{date}}\", date_str)\n", + " \n", + " elements.append(Paragraph(text, styles['CustomBodyText']))\n", + " \n", + " elif node_type == \"orderedList\":\n", + " list_count = 1\n", + " for item in content:\n", + " if item.get(\"type\") == \"listItem\":\n", + " item_content = item.get(\"content\", [])\n", + " for sub_item in item_content:\n", + " if sub_item.get(\"type\") == \"paragraph\":\n", + " text = process_content(sub_item.get(\"content\", []))\n", + " # Replace template variables\n", + " text = text.replace(\"{{organization}}\", org_name)\n", + " text = text.replace(\"{{date}}\", date_str)\n", + " \n", + " # Check if text already starts with numbering (e.g., \"1. \", \"2. \", etc.)\n", + " import re\n", + " if re.match(r'^\\d+\\.\\s', text):\n", + " # Text already has numbering, use as-is\n", + " elements.append(Paragraph(text, styles['ListItem']))\n", + " else:\n", + " # Add numbering\n", + " numbered_text = f\"{list_count}. {text}\"\n", + " elements.append(Paragraph(numbered_text, styles['ListItem']))\n", + " list_count += 1\n", + " elements.append(Spacer(1, 0.05*inch))\n", + " \n", + " elif node_type == \"bulletList\":\n", + " for item in content:\n", + " if item.get(\"type\") == \"listItem\":\n", + " item_content = item.get(\"content\", [])\n", + " for sub_item in item_content:\n", + " if sub_item.get(\"type\") == \"paragraph\":\n", + " text = process_content(sub_item.get(\"content\", []))\n", + " # Replace template variables\n", + " text = text.replace(\"{{organization}}\", org_name)\n", + " text = text.replace(\"{{date}}\", date_str)\n", + " \n", + " # Check if text already starts with a bullet or numbering\n", + " import re\n", + " if re.match(r'^[\\dβ€’\\-\\*]\\.\\s', text) or text.startswith('β€’ '):\n", + " # Text already has bullet/numbering, use as-is\n", + " elements.append(Paragraph(text, styles['ListItem']))\n", + " else:\n", + " # Add bullet\n", + " bullet_text = f\"β€’ {text}\"\n", + " elements.append(Paragraph(bullet_text, styles['ListItem']))\n", + " elements.append(Spacer(1, 0.05*inch))\n", + " \n", + " elif node_type == \"table\":\n", + " table_data = []\n", + " for row in content:\n", + " if row.get(\"type\") == \"tableRow\":\n", + " row_data = []\n", + " for cell in row.get(\"content\", []):\n", + " if cell.get(\"type\") == \"tableCell\":\n", + " text = process_content(cell.get(\"content\", []))\n", + " # Replace template variables\n", + " text = text.replace(\"{{organization}}\", org_name)\n", + " text = text.replace(\"{{date}}\", date_str)\n", + " row_data.append(text)\n", + " table_data.append(row_data)\n", + " \n", + " if table_data:\n", + " # Create the table\n", + " t = Table(table_data, colWidths=None)\n", + " \n", + " # Add style to table\n", + " table_style = TableStyle([\n", + " # Header row style\n", + " ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#3498db')),\n", + " ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),\n", + " ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),\n", + " ('FONTSIZE', (0, 0), (-1, 0), 12),\n", + " ('BOTTOMPADDING', (0, 0), (-1, 0), 12),\n", + " \n", + " # Data rows\n", + " ('BACKGROUND', (0, 1), (-1, -1), colors.beige),\n", + " ('TEXTCOLOR', (0, 1), (-1, -1), colors.black),\n", + " ('FONTNAME', (0, 1), (-1, -1), 'Helvetica'),\n", + " ('FONTSIZE', (0, 1), (-1, -1), 10),\n", + " ('GRID', (0, 0), (-1, -1), 1, colors.grey),\n", + " ('ALIGN', (0, 0), (-1, -1), 'LEFT'),\n", + " ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),\n", + " ('TOPPADDING', (0, 1), (-1, -1), 6),\n", + " ('BOTTOMPADDING', (0, 1), (-1, -1), 6),\n", + " ])\n", + " \n", + " # Alternate row colors\n", + " for i in range(2, len(table_data), 2):\n", + " table_style.add('BACKGROUND', (0, i), (-1, i), colors.HexColor('#f9f9f9'))\n", + " \n", + " t.setStyle(table_style)\n", + " elements.append(t)\n", + " elements.append(Spacer(1, 0.1*inch))\n", + " \n", + " # Build PDF\n", + " if auto_generated_filename:\n", + " print(f\"πŸ“„ Auto-generated filename from document title: {output_filename}\")\n", + " print(f\"Generating PDF: {full_output_path}\")\n", + " doc.build(elements)\n", + " print(f\"βœ… PDF generated successfully: {full_output_path}\")\n", + "\n", + "# Create an alias for backward compatibility\n", + "generate_pdf = generate_pdf_with_reportlab\n" + ] + }, + { + "cell_type": "raw", + "metadata": { + "vscode": { + "languageId": "raw" + } + }, + "source": [ + "## Input Your JSON Data\n", + "\n", + "Paste your JSON data in the cell below. \n", + "\n", + "**IMPORTANT:** Use square brackets `[]` (a list) to preserve document order, NOT curly braces `{}` (a set). Sets are unordered in Python and will scramble your content!\n" + ] + }, + { + "cell_type": "code", + "execution_count": 460, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "DEBUG: First policy's content (first 3 items):\n", + "\n", + "Item 0:\n", + "Length: 79\n", + "First 100 chars: {\"type\": \"heading\", \"content\": [{\"text\": \"Table of Contents\", \"type\": \"text\"}]}\n", + "Raw: '{\"type\": \"heading\", \"content\": [{\"text\": \"Table of Contents\", \"type\": \"text\"}]}'\n", + "\n", + "Item 1:\n", + "Length: 898\n", + "First 100 chars: {\"type\": \"orderedList\", \"content\": [{\"type\": \"listItem\", \"content\": [{\"type\": \"paragraph\", \"content\"\n", + "Raw: '{\"type\": \"orderedList\", \"content\": [{\"type\": \"listItem\", \"content\": [{\"type\": \"paragraph\", \"content\"'\n", + "\n", + "Item 2:\n", + "Length: 79\n", + "First 100 chars: {\"type\": \"heading\", \"content\": [{\"text\": \"Executive Summary\", \"type\": \"text\"}]}\n", + "Raw: '{\"type\": \"heading\", \"content\": [{\"text\": \"Executive Summary\", \"type\": \"text\"}]}'\n" + ] + } + ], + "source": [ + "# Debug: Let's see what the raw JSON strings look like\n", + "if policies_data and len(policies_data) > 0:\n", + " print(\"DEBUG: First policy's content (first 3 items):\")\n", + " for i, item in enumerate(policies_data[0]['content'][:3]):\n", + " print(f\"\\nItem {i}:\")\n", + " print(f\"Length: {len(item)}\")\n", + " print(f\"First 100 chars: {item[:100]}\")\n", + " print(f\"Raw: {repr(item[:100])}\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": 461, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Loading policies from source_data.txt...\n", + "\n", + "βœ… Loaded 19 policies successfully!\n", + "\n", + "Available policies:\n", + "------------------------------------------------------------\n", + " 1. Business Continuity Policy\n", + " 2. Information Security Program\n", + " 3. Capacity & Performance Management\n", + " 4. Change Management Policy \n", + " 5. Encryption & Cryptographic Control Policy\n", + " 6. Logging Policy\n", + " 7. Physical Security Policy\n", + " 8. Vulnerability Management Policy\n", + " 9. Acceptable Use Policy\n", + "10. Access Control Policy\n", + "11. Asset Management Policy\n", + "12. Endpoint Protection Policy\n", + "13. Incident Response Policy\n", + "14. Information Protection Policy\n", + "15. Privacy Policy\n", + "16. Risk Management Policy \n", + "17. Secure Development Policy\n", + "18. Security Awareness & Training Policy\n", + "19. Third-Party Risk Management Policy\n", + "------------------------------------------------------------\n", + "\n", + "πŸ“‹ Selected policy: Business Continuity Policy\n", + " Description: This policy ensures the organization can quickly restore critical operations after a disruption by m...\n" + ] + } + ], + "source": [ + "# Load data from source_data.txt file\n", + "import csv\n", + "import ast\n", + "\n", + "def read_source_data(filepath='source_data.txt'):\n", + " \"\"\"Read and parse the source data file\"\"\"\n", + " policies = []\n", + " \n", + " try:\n", + " with open(filepath, 'r', encoding='utf-8') as f:\n", + " # Read as CSV with comma delimiter\n", + " reader = csv.reader(f, delimiter=',')\n", + " \n", + " # Skip header if present\n", + " header = next(reader, None)\n", + " \n", + " for row in reader:\n", + " if len(row) > 7: # Ensure we have enough columns\n", + " policy_id = row[0]\n", + " policy_name = row[1]\n", + " policy_desc = row[2]\n", + " \n", + " # The content appears to be in a set format starting from column 7\n", + " # Parse the set containing JSON strings\n", + " content_set_str = row[7]\n", + " \n", + " if content_set_str and content_set_str.strip():\n", + " try:\n", + " # Parse the set string\n", + " # Remove the outer quotes if present\n", + " content_set_str = content_set_str.strip('\"')\n", + " \n", + " # Convert the set string to a list\n", + " # The content is a set of JSON strings\n", + " content_items = []\n", + " \n", + " # The content is formatted as a set with escaped JSON strings\n", + " # Remove the curly braces and split by \",\"\n", + " content_str = content_set_str.strip('{}')\n", + " \n", + " # Split carefully to handle JSON objects that contain commas\n", + " import re\n", + " # Use regex to split by commas that are between JSON objects\n", + " json_pattern = r'\"\\s*,\\s*\"'\n", + " json_strings = re.split(json_pattern, content_str)\n", + " \n", + " for i, json_str in enumerate(json_strings):\n", + " # Clean up the JSON string\n", + " json_str = json_str.strip()\n", + " \n", + " # Remove leading/trailing quotes if present\n", + " if json_str.startswith('\"'):\n", + " json_str = json_str[1:]\n", + " if json_str.endswith('\"'):\n", + " json_str = json_str[:-1]\n", + " \n", + " # Replace escaped quotes with regular quotes\n", + " json_str = json_str.replace('\\\\\"\"', '\"')\n", + " json_str = json_str.replace('\\\\\"', '\"')\n", + " json_str = json_str.replace('\"\"', '\"')\n", + " \n", + " if json_str:\n", + " content_items.append(json_str)\n", + " \n", + " if content_items:\n", + " policies.append({\n", + " 'id': policy_id,\n", + " 'name': policy_name,\n", + " 'description': policy_desc,\n", + " 'content': content_items # List of JSON strings\n", + " })\n", + " except Exception as e:\n", + " print(f\"Error parsing policy {policy_name}: {e}\")\n", + " continue\n", + " \n", + " except Exception as e:\n", + " print(f\"Error reading source file: {e}\")\n", + " return []\n", + " \n", + " return policies\n", + "\n", + "# Load all policies from the source file\n", + "print(\"Loading policies from source_data.txt...\")\n", + "policies_data = read_source_data()\n", + "\n", + "if policies_data:\n", + " print(f\"\\nβœ… Loaded {len(policies_data)} policies successfully!\\n\")\n", + " print(\"Available policies:\")\n", + " print(\"-\" * 60)\n", + " for i, policy in enumerate(policies_data):\n", + " print(f\"{i+1:2d}. {policy['name']}\")\n", + " print(\"-\" * 60)\n", + " \n", + " # Select a policy to convert (change this index to select a different policy)\n", + " policy_index = 0 # First policy by default\n", + " selected_policy = policies_data[policy_index]\n", + " \n", + " print(f\"\\nπŸ“‹ Selected policy: {selected_policy['name']}\")\n", + " print(f\" Description: {selected_policy['description'][:100]}...\")\n", + " \n", + " # Set the JSON input to the selected policy's content\n", + " json_input = selected_policy['content']\n", + " \n", + "else:\n", + " print(\"❌ No policies found in source_data.txt\")\n", + " json_input = []\n" + ] + }, + { + "cell_type": "code", + "execution_count": 462, + "metadata": {}, + "outputs": [], + "source": [ + "# PASTE YOUR JSON DATA HERE\n", + "# IMPORTANT: Use square brackets [] for a list to preserve order, NOT curly braces {} for a set!\n", + "# Example with the provided data:\n", + "json_input = [\"{\\\"type\\\": \\\"heading\\\", \\\"content\\\": [{\\\"text\\\": \\\"Executive Summary\\\", \\\"type\\\": \\\"text\\\"}]}\",\"{\\\"type\\\": \\\"paragraph\\\", \\\"content\\\": [{\\\"text\\\": \\\"This Endpoint Protection Policy defines mandatory controls for Casper Studios’ cloud and home-office endpoints to meet SOC 2 requirements. It assigns roles, specifies measurable review cycles, and leverages AWS us-east-1 and Google Workspace integrations to protect employee data. All unresolved configuration details are marked for review.\\\", \\\"type\\\": \\\"text\\\"}]}\",\"{\\\"type\\\": \\\"heading\\\", \\\"content\\\": [{\\\"text\\\": \\\"Table of Contents\\\", \\\"type\\\": \\\"text\\\"}]}\",\"{\\\"type\\\": \\\"bulletList\\\", \\\"content\\\": [{\\\"type\\\": \\\"listItem\\\", \\\"content\\\": [{\\\"type\\\": \\\"paragraph\\\", \\\"content\\\": [{\\\"text\\\": \\\"1. Document Content Page\\\", \\\"type\\\": \\\"text\\\"}]}]}, {\\\"type\\\": \\\"listItem\\\", \\\"content\\\": [{\\\"type\\\": \\\"paragraph\\\", \\\"content\\\": [{\\\"text\\\": \\\"2. Applicability and Scope\\\", \\\"type\\\": \\\"text\\\"}]}]}, {\\\"type\\\": \\\"listItem\\\", \\\"content\\\": [{\\\"type\\\": \\\"paragraph\\\", \\\"content\\\": [{\\\"text\\\": \\\"3. Controls\\\", \\\"type\\\": \\\"text\\\"}]}]}, {\\\"type\\\": \\\"listItem\\\", \\\"content\\\": [{\\\"type\\\": \\\"paragraph\\\", \\\"content\\\": [{\\\"text\\\": \\\"4. Exceptions Process\\\", \\\"type\\\": \\\"text\\\"}]}]}, {\\\"type\\\": \\\"listItem\\\", \\\"content\\\": [{\\\"type\\\": \\\"paragraph\\\", \\\"content\\\": [{\\\"text\\\": \\\"5. Violations and Disciplinary Action\\\", \\\"type\\\": \\\"text\\\"}]}]}, {\\\"type\\\": \\\"listItem\\\", \\\"content\\\": [{\\\"type\\\": \\\"paragraph\\\", \\\"content\\\": [{\\\"text\\\": \\\"6. Auditor Evidence Artefacts\\\", \\\"type\\\": \\\"text\\\"}]}]}]}\",\"{\\\"type\\\": \\\"heading\\\", \\\"content\\\": [{\\\"text\\\": \\\"2. Applicability and Scope\\\", \\\"type\\\": \\\"text\\\"}]}\",\"{\\\"type\\\": \\\"paragraph\\\", \\\"content\\\": [{\\\"text\\\": \\\"This policy applies to all Casper Studios employees, contractors, and third parties configuring, using, or managing company-provided laptops, home-office desktops, and AWS EC2 instances in us-east-1 that access, store, or process employee data.\\\", \\\"type\\\": \\\"text\\\"}]}\",\"{\\\"type\\\": \\\"heading\\\", \\\"content\\\": [{\\\"text\\\": \\\"3. Controls\\\", \\\"type\\\": \\\"text\\\"}]}\",\"{\\\"type\\\": \\\"heading\\\", \\\"content\\\": [{\\\"text\\\": \\\"3.1 Malware Protection\\\", \\\"type\\\": \\\"text\\\"}]}\",\"{\\\"type\\\": \\\"orderedList\\\", \\\"content\\\": [{\\\"type\\\": \\\"listItem\\\", \\\"content\\\": [{\\\"type\\\": \\\"paragraph\\\", \\\"content\\\": [{\\\"text\\\": \\\"All endpoints accessing AWS-hosted applications or storing employee data must have approved anti-malware software (e.g., Sophos Endpoint Protection agent version) installed, configured, and updated daily; the Security Administrator shall verify signature updates quarterly.\\\", \\\"type\\\": \\\"text\\\"}]}]}]}\",\"{\\\"type\\\": \\\"heading\\\", \\\"content\\\": [{\\\"text\\\": \\\"3.2 Inventory and Encryption\\\", \\\"type\\\": \\\"text\\\"}]}\",\"{\\\"type\\\": \\\"orderedList\\\", \\\"content\\\": [{\\\"type\\\": \\\"listItem\\\", \\\"content\\\": [{\\\"type\\\": \\\"paragraph\\\", \\\"content\\\": [{\\\"text\\\": \\\"The IT Manager must maintain an automated endpoint inventory via AWS Config and a CMDB, reviewing asset records quarterly for accuracy and completeness.\\\", \\\"type\\\": \\\"text\\\"}]}]}, {\\\"type\\\": \\\"listItem\\\", \\\"content\\\": [{\\\"type\\\": \\\"paragraph\\\", \\\"content\\\": [{\\\"text\\\": \\\"All company-provided laptops and EBS volumes shall be encrypted with AES-256 using AWS KMS keys managed in us-east-1; compliance scans shall run monthly.\\\", \\\"type\\\": \\\"text\\\"}]}]}]}\",\"{\\\"type\\\": \\\"heading\\\", \\\"content\\\": [{\\\"text\\\": \\\"3.3 Endpoint Security Administration\\\", \\\"type\\\": \\\"text\\\"}]}\",\"{\\\"type\\\": \\\"orderedList\\\", \\\"content\\\": [{\\\"type\\\": \\\"listItem\\\", \\\"content\\\": [{\\\"type\\\": \\\"paragraph\\\", \\\"content\\\": [{\\\"text\\\": \\\"The Security Administrator shall document and maintain endpoint configuration procedures in the internal Security Wiki, reviewing them annually.\\\", \\\"type\\\": \\\"text\\\"}]}]}, {\\\"type\\\": \\\"listItem\\\", \\\"content\\\": [{\\\"type\\\": \\\"paragraph\\\", \\\"content\\\": [{\\\"text\\\": \\\"Encryption keys and anti-malware policies must be centrally managed via AWS IAM and Google Workspace APIs, with access reviewed quarterly by Executive Management.\\\", \\\"type\\\": \\\"text\\\"}]}]}]}\",\"{\\\"type\\\": \\\"heading\\\", \\\"content\\\": [{\\\"text\\\": \\\"4. Exceptions Process\\\", \\\"type\\\": \\\"text\\\"}]}\",\"{\\\"type\\\": \\\"paragraph\\\", \\\"content\\\": [{\\\"text\\\": \\\"Employees must request endpoint exceptions through Linear tickets, including business justification, compensating controls, and duration capped at 30 days. The Information Security Officer and IT Manager shall jointly approve, document, and time-limit each exception; all exceptions shall be re-evaluated at expiration.\\\", \\\"type\\\": \\\"text\\\"}]}\",\"{\\\"type\\\": \\\"heading\\\", \\\"content\\\": [{\\\"text\\\": \\\"5. Violations and Disciplinary Action\\\", \\\"type\\\": \\\"text\\\"}]}\",\"{\\\"type\\\": \\\"paragraph\\\", \\\"content\\\": [{\\\"text\\\": \\\"Automated monitoring via AWS CloudWatch and intrusion detection systems shall detect non-compliance. Suspected violations must be reported to the Information Security Officer and HR within 24 hours. Confirmed violations will trigger HR disciplinary tiers (verbal warning through termination) and may include immediate access revocation or device quarantine.\\\", \\\"type\\\": \\\"text\\\"}]}\",\"{\\\"type\\\": \\\"heading\\\", \\\"content\\\": [{\\\"text\\\": \\\"6. Auditor Evidence Artefacts\\\", \\\"type\\\": \\\"text\\\"}]}\",\"{\\\"type\\\": \\\"bulletList\\\", \\\"content\\\": [{\\\"type\\\": \\\"listItem\\\", \\\"content\\\": [{\\\"type\\\": \\\"paragraph\\\", \\\"content\\\": [{\\\"text\\\": \\\"AWS Config export reports and KMS key configuration screenshots\\\", \\\"type\\\": \\\"text\\\"}]}]}, {\\\"type\\\": \\\"listItem\\\", \\\"content\\\": [{\\\"type\\\": \\\"paragraph\\\", \\\"content\\\": [{\\\"text\\\": \\\"Daily anti-malware update logs and quarterly verification records\\\", \\\"type\\\": \\\"text\\\"}]}]}, {\\\"type\\\": \\\"listItem\\\", \\\"content\\\": [{\\\"type\\\": \\\"paragraph\\\", \\\"content\\\": [{\\\"text\\\": \\\"CMDB asset inventory exports with quarterly review annotations\\\", \\\"type\\\": \\\"text\\\"}]}]}, {\\\"type\\\": \\\"listItem\\\", \\\"content\\\": [{\\\"type\\\": \\\"paragraph\\\", \\\"content\\\": [{\\\"text\\\": \\\"Linear exception request and approval tickets\\\", \\\"type\\\": \\\"text\\\"}]}]}, {\\\"type\\\": \\\"listItem\\\", \\\"content\\\": [{\\\"type\\\": \\\"paragraph\\\", \\\"content\\\": [{\\\"text\\\": \\\"Incident reports and HR disciplinary action logs\\\", \\\"type\\\": \\\"text\\\"}]}]}, {\\\"type\\\": \\\"listItem\\\", \\\"content\\\": [{\\\"type\\\": \\\"paragraph\\\", \\\"content\\\": [{\\\"text\\\": \\\"Minutes from quarterly security review meetings\\\", \\\"type\\\": \\\"text\\\"}]}]}]}\"]\n", + "\n", + "\n" + ] + }, + { + "cell_type": "raw", + "metadata": { + "vscode": { + "languageId": "raw" + } + }, + "source": [ + "## Using the Source Data File\n", + "\n", + "The notebook now loads policies from `source_data.txt`. The file contains multiple policies, and you can:\n", + "\n", + "1. **Use the default selection** - The first policy is selected by default\n", + "2. **Select a different policy** - Change `policy_index` in the cell above (0-based index)\n", + "3. **Override with custom JSON** - Uncomment and modify the `json_input` line in the cell above\n", + "4. **Generate ALL policies at once** - See the \"Batch Processing\" section below!\n", + "\n", + "The selected policy's content is stored in `json_input` and will be used for PDF generation.\n" + ] + }, + { + "cell_type": "raw", + "metadata": { + "vscode": { + "languageId": "raw" + } + }, + "source": [ + "## Generate PDF\n", + "\n", + "Configure your PDF generation options and run the cell below. \n", + "\n", + "**Note:** PDFs will be saved in the `pdfs/` subdirectory (created automatically if it doesn't exist).\n" + ] + }, + { + "cell_type": "code", + "execution_count": 463, + "metadata": {}, + "outputs": [ + { + "ename": "UnboundLocalError", + "evalue": "local variable 're' referenced before assignment", + "output_type": "error", + "traceback": [ + "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[0;31mUnboundLocalError\u001b[0m Traceback (most recent call last)", + "Cell \u001b[0;32mIn[463], line 10\u001b[0m\n\u001b[1;32m 6\u001b[0m \u001b[38;5;66;03m# Generate the PDF\u001b[39;00m\n\u001b[1;32m 7\u001b[0m \u001b[38;5;66;03m# If output_filename is None, it will use the first heading as the filename\u001b[39;00m\n\u001b[1;32m 8\u001b[0m \u001b[38;5;66;03m# If you selected a policy from the source file, it will include name and description\u001b[39;00m\n\u001b[1;32m 9\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m \u001b[38;5;124m'\u001b[39m\u001b[38;5;124mselected_policy\u001b[39m\u001b[38;5;124m'\u001b[39m \u001b[38;5;129;01min\u001b[39;00m \u001b[38;5;28mglobals\u001b[39m() \u001b[38;5;129;01mand\u001b[39;00m selected_policy:\n\u001b[0;32m---> 10\u001b[0m \u001b[43mgenerate_pdf\u001b[49m\u001b[43m(\u001b[49m\n\u001b[1;32m 11\u001b[0m \u001b[43m \u001b[49m\u001b[43mjson_data\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mjson_input\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 12\u001b[0m \u001b[43m \u001b[49m\u001b[43moutput_filename\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43moutput_filename\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 13\u001b[0m \u001b[43m \u001b[49m\u001b[43morganization_name\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43morganization_name\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 14\u001b[0m \u001b[43m \u001b[49m\u001b[43mcustom_date\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mcustom_date\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 15\u001b[0m \u001b[43m \u001b[49m\u001b[43mpolicy_name\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mselected_policy\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mget\u001b[49m\u001b[43m(\u001b[49m\u001b[38;5;124;43m'\u001b[39;49m\u001b[38;5;124;43mname\u001b[39;49m\u001b[38;5;124;43m'\u001b[39;49m\u001b[43m)\u001b[49m\u001b[43m,\u001b[49m\n\u001b[1;32m 16\u001b[0m \u001b[43m \u001b[49m\u001b[43mpolicy_description\u001b[49m\u001b[38;5;241;43m=\u001b[39;49m\u001b[43mselected_policy\u001b[49m\u001b[38;5;241;43m.\u001b[39;49m\u001b[43mget\u001b[49m\u001b[43m(\u001b[49m\u001b[38;5;124;43m'\u001b[39;49m\u001b[38;5;124;43mdescription\u001b[39;49m\u001b[38;5;124;43m'\u001b[39;49m\u001b[43m)\u001b[49m\n\u001b[1;32m 17\u001b[0m \u001b[43m \u001b[49m\u001b[43m)\u001b[49m\n\u001b[1;32m 18\u001b[0m \u001b[38;5;28;01melse\u001b[39;00m:\n\u001b[1;32m 19\u001b[0m generate_pdf(\n\u001b[1;32m 20\u001b[0m json_data\u001b[38;5;241m=\u001b[39mjson_input,\n\u001b[1;32m 21\u001b[0m output_filename\u001b[38;5;241m=\u001b[39moutput_filename,\n\u001b[1;32m 22\u001b[0m organization_name\u001b[38;5;241m=\u001b[39morganization_name,\n\u001b[1;32m 23\u001b[0m custom_date\u001b[38;5;241m=\u001b[39mcustom_date\n\u001b[1;32m 24\u001b[0m )\n", + "Cell \u001b[0;32mIn[459], line 41\u001b[0m, in \u001b[0;36mgenerate_pdf_with_reportlab\u001b[0;34m(json_data, output_filename, organization_name, custom_date, policy_name, policy_description)\u001b[0m\n\u001b[1;32m 36\u001b[0m \u001b[38;5;28;01mbreak\u001b[39;00m\n\u001b[1;32m 38\u001b[0m \u001b[38;5;28;01mif\u001b[39;00m title:\n\u001b[1;32m 39\u001b[0m \u001b[38;5;66;03m# Convert title to filename-friendly format\u001b[39;00m\n\u001b[1;32m 40\u001b[0m \u001b[38;5;66;03m# Remove special characters and replace spaces with underscores\u001b[39;00m\n\u001b[0;32m---> 41\u001b[0m filename_base \u001b[38;5;241m=\u001b[39m \u001b[43mre\u001b[49m\u001b[38;5;241m.\u001b[39msub(\u001b[38;5;124mr\u001b[39m\u001b[38;5;124m'\u001b[39m\u001b[38;5;124m[^\u001b[39m\u001b[38;5;124m\\\u001b[39m\u001b[38;5;124mw\u001b[39m\u001b[38;5;124m\\\u001b[39m\u001b[38;5;124ms-]\u001b[39m\u001b[38;5;124m'\u001b[39m, \u001b[38;5;124m'\u001b[39m\u001b[38;5;124m'\u001b[39m, title\u001b[38;5;241m.\u001b[39mlower())\n\u001b[1;32m 42\u001b[0m filename_base \u001b[38;5;241m=\u001b[39m re\u001b[38;5;241m.\u001b[39msub(\u001b[38;5;124mr\u001b[39m\u001b[38;5;124m'\u001b[39m\u001b[38;5;124m[-\u001b[39m\u001b[38;5;124m\\\u001b[39m\u001b[38;5;124ms]+\u001b[39m\u001b[38;5;124m'\u001b[39m, \u001b[38;5;124m'\u001b[39m\u001b[38;5;124m_\u001b[39m\u001b[38;5;124m'\u001b[39m, filename_base)\n\u001b[1;32m 43\u001b[0m output_filename \u001b[38;5;241m=\u001b[39m \u001b[38;5;124mf\u001b[39m\u001b[38;5;124m\"\u001b[39m\u001b[38;5;132;01m{\u001b[39;00mfilename_base\u001b[38;5;132;01m}\u001b[39;00m\u001b[38;5;124m.pdf\u001b[39m\u001b[38;5;124m\"\u001b[39m\n", + "\u001b[0;31mUnboundLocalError\u001b[0m: local variable 're' referenced before assignment" + ] + } + ], + "source": [ + "# Configuration options\n", + "output_filename = None # Set to None to auto-generate from document title, or specify a custom filename\n", + "organization_name = \"Casper Studios\" # Change this to your organization name\n", + "custom_date = None # Set to a specific date string or leave as None for today's date\n", + "\n", + "# Generate the PDF\n", + "# If output_filename is None, it will use the first heading as the filename\n", + "# If you selected a policy from the source file, it will include name and description\n", + "if 'selected_policy' in globals() and selected_policy:\n", + " generate_pdf(\n", + " json_data=json_input,\n", + " output_filename=output_filename,\n", + " organization_name=organization_name,\n", + " custom_date=custom_date,\n", + " policy_name=selected_policy.get('name'),\n", + " policy_description=selected_policy.get('description')\n", + " )\n", + "else:\n", + " generate_pdf(\n", + " json_data=json_input,\n", + " output_filename=output_filename,\n", + " organization_name=organization_name,\n", + " custom_date=custom_date\n", + " )\n" + ] + }, + { + "cell_type": "raw", + "metadata": { + "vscode": { + "languageId": "raw" + } + }, + "source": [ + "## Alternative: Simple String Input\n", + "\n", + "If you prefer to paste your JSON as a simple string, use this approach:\n" + ] + }, + { + "cell_type": "code", + "execution_count": 446, + "metadata": {}, + "outputs": [], + "source": [ + "# Alternative method: paste your entire JSON string here\n", + "json_string = '''\n", + "# PASTE YOUR JSON STRING HERE\n", + "# Just replace this comment with your JSON data\n", + "'''\n", + "\n", + "# Uncomment the lines below to use this method\n", + "# generate_pdf(\n", + "# json_data=json_string,\n", + "# output_filename=\"output.pdf\",\n", + "# organization_name=\"Your Company\",\n", + "# custom_date=None\n", + "# )\n" + ] + }, + { + "cell_type": "raw", + "metadata": { + "vscode": { + "languageId": "raw" + } + }, + "source": [ + "## πŸš€ Batch Processing: Generate PDFs for All Policies\n", + "\n", + "**Want to generate all 19 policies at once?** The cells below provide:\n", + "\n", + "1. A function `generate_all_pdfs()` that processes all policies from source_data.txt\n", + "2. Ready-to-run examples - just uncomment and execute!\n", + "\n", + "This will create 19 PDFs in the `pdfs/` directory, one for each policy.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": 447, + "metadata": {}, + "outputs": [], + "source": [ + "# Generate PDFs for all policies in the source file\n", + "def generate_all_pdfs(organization_name=\"Casper Studios\", custom_date=None):\n", + " \"\"\"Generate PDFs for all policies loaded from source_data.txt\"\"\"\n", + " import re # For filename sanitization\n", + " \n", + " if not policies_data:\n", + " print(\"❌ No policies loaded. Please run the data loading cell first.\")\n", + " return\n", + " \n", + " print(f\"πŸš€ Starting batch PDF generation for {len(policies_data)} policies...\\n\")\n", + " \n", + " successful = 0\n", + " failed = 0\n", + " \n", + " for i, policy in enumerate(policies_data):\n", + " try:\n", + " print(f\"Processing {i+1}/{len(policies_data)}: {policy['name']}\")\n", + " \n", + " # Generate filename from policy name instead of document content\n", + " policy_filename = re.sub(r'[^\\w\\s-]', '', policy['name'].lower())\n", + " policy_filename = re.sub(r'[-\\s]+', '_', policy_filename)\n", + " policy_filename = f\"{policy_filename}.pdf\"\n", + " \n", + " # Generate PDF with policy name as filename\n", + " generate_pdf(\n", + " json_data=policy['content'],\n", + " output_filename=policy_filename,\n", + " organization_name=organization_name,\n", + " custom_date=custom_date,\n", + " policy_name=policy['name'],\n", + " policy_description=policy['description']\n", + " )\n", + " \n", + " successful += 1\n", + " print(\"\") # Add blank line between policies\n", + " \n", + " except Exception as e:\n", + " print(f\"❌ Failed to generate PDF for {policy['name']}: {str(e)}\\n\")\n", + " failed += 1\n", + " \n", + " print(f\"\\n{'='*60}\")\n", + " print(f\"βœ… Batch processing complete!\")\n", + " print(f\" Successful: {successful}\")\n", + " print(f\" Failed: {failed}\")\n", + " print(f\" Total: {len(policies_data)}\")\n", + " print(f\"{'='*60}\")\n", + "\n", + "# ⚑ GENERATE ALL PDFs AT ONCE ⚑\n", + "# Uncomment and run ONE of the options below to generate PDFs for ALL policies:\n", + "\n", + "# Option 1: Use default settings\n", + "# generate_all_pdfs()\n", + "\n", + "# Option 2: Custom organization name\n", + "# generate_all_pdfs(organization_name=\"Your Company Name\")\n", + "\n", + "# Option 3: Custom organization and date\n", + "# generate_all_pdfs(organization_name=\"Casper Studios\", custom_date=\"2024-01-01\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": 448, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "πŸš€ Starting batch PDF generation for 19 policies...\n", + "\n", + "Processing 1/19: Business Continuity Policy\n", + "Generating PDF: pdfs/business_continuity_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/business_continuity_policy.pdf\n", + "\n", + "Processing 2/19: Information Security Program\n", + "Generating PDF: pdfs/information_security_program.pdf\n", + "βœ… PDF generated successfully: pdfs/information_security_program.pdf\n", + "\n", + "Processing 3/19: Capacity & Performance Management\n", + "Generating PDF: pdfs/capacity_performance_management.pdf\n", + "βœ… PDF generated successfully: pdfs/capacity_performance_management.pdf\n", + "\n", + "Processing 4/19: Change Management Policy \n", + "Generating PDF: pdfs/change_management_policy_.pdf\n", + "βœ… PDF generated successfully: pdfs/change_management_policy_.pdf\n", + "\n", + "Processing 5/19: Encryption & Cryptographic Control Policy\n", + "Generating PDF: pdfs/encryption_cryptographic_control_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/encryption_cryptographic_control_policy.pdf\n", + "\n", + "Processing 6/19: Logging Policy\n", + "Generating PDF: pdfs/logging_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/logging_policy.pdf\n", + "\n", + "Processing 7/19: Physical Security Policy\n", + "Generating PDF: pdfs/physical_security_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/physical_security_policy.pdf\n", + "\n", + "Processing 8/19: Vulnerability Management Policy\n", + "Generating PDF: pdfs/vulnerability_management_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/vulnerability_management_policy.pdf\n", + "\n", + "Processing 9/19: Acceptable Use Policy\n", + "Generating PDF: pdfs/acceptable_use_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/acceptable_use_policy.pdf\n", + "\n", + "Processing 10/19: Access Control Policy\n", + "Generating PDF: pdfs/access_control_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/access_control_policy.pdf\n", + "\n", + "Processing 11/19: Asset Management Policy\n", + "Generating PDF: pdfs/asset_management_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/asset_management_policy.pdf\n", + "\n", + "Processing 12/19: Endpoint Protection Policy\n", + "Generating PDF: pdfs/endpoint_protection_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/endpoint_protection_policy.pdf\n", + "\n", + "Processing 13/19: Incident Response Policy\n", + "Generating PDF: pdfs/incident_response_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/incident_response_policy.pdf\n", + "\n", + "Processing 14/19: Information Protection Policy\n", + "Generating PDF: pdfs/information_protection_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/information_protection_policy.pdf\n", + "\n", + "Processing 15/19: Privacy Policy\n", + "Generating PDF: pdfs/privacy_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/privacy_policy.pdf\n", + "\n", + "Processing 16/19: Risk Management Policy \n", + "Generating PDF: pdfs/risk_management_policy_.pdf\n", + "βœ… PDF generated successfully: pdfs/risk_management_policy_.pdf\n", + "\n", + "Processing 17/19: Secure Development Policy\n", + "Generating PDF: pdfs/secure_development_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/secure_development_policy.pdf\n", + "\n", + "Processing 18/19: Security Awareness & Training Policy\n", + "Generating PDF: pdfs/security_awareness_training_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/security_awareness_training_policy.pdf\n", + "\n", + "Processing 19/19: Third-Party Risk Management Policy\n", + "Generating PDF: pdfs/third_party_risk_management_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/third_party_risk_management_policy.pdf\n", + "\n", + "\n", + "============================================================\n", + "βœ… Batch processing complete!\n", + " Successful: 19\n", + " Failed: 0\n", + " Total: 19\n", + "============================================================\n" + ] + } + ], + "source": [ + "# Run this cell to generate ALL PDFs at once!\n", + "generate_all_pdfs()" + ] + }, + { + "cell_type": "code", + "execution_count": 449, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Analyzing: Information Protection Policy\n", + "\n", + "\n", + "Found orderedList at index 1:\n", + " Item 0: 1. Document Content Page...\n", + " Item 1: 2. Executive Summary...\n", + " Item 2: 3. Applicability and Scope...\n", + " Item 3: 4. Controls...\n", + " Item 4: 5. Exceptions Process...\n", + " Item 5: 6. Violations and Disciplinary Action...\n", + " Item 6: 7. Auditor Evidence Artefacts...\n", + "\n", + "Found orderedList at index 9:\n", + " Item 0: Retention periods must be defined per data classification (e.g., employee record...\n", + " Item 1: Media decommissioning (e.g., AWS EBS volumes, local disk images) must employ cry...\n" + ] + } + ], + "source": [ + "# Let's debug the list parsing issue\n", + "# Check what the actual content looks like for lists\n", + "\n", + "# Select Information Protection Policy (index 13)\n", + "policy_index = 13\n", + "selected_policy = policies_data[policy_index]\n", + "print(f\"Analyzing: {selected_policy['name']}\\n\")\n", + "\n", + "# Parse the JSON and look for lists\n", + "import json\n", + "for i, json_str in enumerate(selected_policy['content'][:10]): # First 10 items\n", + " try:\n", + " node = json.loads(json_str)\n", + " if node.get('type') in ['orderedList', 'bulletList']:\n", + " print(f\"\\nFound {node['type']} at index {i}:\")\n", + " for j, item in enumerate(node.get('content', [])):\n", + " if item.get('type') == 'listItem':\n", + " for para in item.get('content', []):\n", + " if para.get('type') == 'paragraph':\n", + " text = ''.join([t.get('text', '') for t in para.get('content', [])])\n", + " print(f\" Item {j}: {text[:80]}...\")\n", + " except:\n", + " pass\n" + ] + }, + { + "cell_type": "code", + "execution_count": 450, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "Node 0: heading\n", + " Heading: \n", + "\n", + "Node 1: orderedList\n", + "\n", + "Node 2: heading\n", + " Heading: \n", + "\n", + "Node 3: paragraph\n", + "\n", + "Node 4: heading\n", + " Heading: \n" + ] + } + ], + "source": [ + "# Let's look at a specific bulletList example\n", + "# Find the Table of Contents which seems to be the problematic list\n", + "import json\n", + "\n", + "# Look for the Table of Contents in Information Protection Policy\n", + "policy_index = 13\n", + "selected_policy = policies_data[policy_index]\n", + "\n", + "for i, json_str in enumerate(selected_policy['content'][:5]):\n", + " try:\n", + " node = json.loads(json_str)\n", + " print(f\"\\nNode {i}: {node.get('type')}\")\n", + " if node.get('type') == 'heading':\n", + " text = ''.join([t.get('text', '') for t in node.get('content', [])[0].get('content', [])])\n", + " print(f\" Heading: {text}\")\n", + " elif node.get('type') == 'bulletList':\n", + " print(\" BulletList items:\")\n", + " for item in node.get('content', []):\n", + " if item.get('type') == 'listItem':\n", + " for para in item.get('content', []):\n", + " if para.get('type') == 'paragraph':\n", + " text = ''.join([t.get('text', '') for t in para.get('content', [])])\n", + " print(f\" - '{text}'\")\n", + " except Exception as e:\n", + " print(f\" Error: {e}\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Test the fixed list parsing with Information Protection Policy\n", + "policy_index = 13 # Information Protection Policy\n", + "selected_policy = policies_data[policy_index]\n", + "\n", + "print(f\"Regenerating PDF for: {selected_policy['name']}\")\n", + "\n", + "# Generate with the fixed code\n", + "generate_pdf(\n", + " json_data=selected_policy['content'],\n", + " output_filename=\"information_protection_policy_fixed.pdf\",\n", + " organization_name=\"Casper Studios\",\n", + " custom_date=None,\n", + " policy_name=selected_policy['name'],\n", + " policy_description=selected_policy['description']\n", + ")\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Regenerate all PDFs with the fixed list parsing\n", + "print(\"πŸ”§ Regenerating all PDFs with fixed list parsing and improved spacing...\\n\")\n", + "generate_all_pdfs()\n" + ] + }, + { + "cell_type": "raw", + "metadata": { + "vscode": { + "languageId": "raw" + } + }, + "source": [ + "## πŸŽ‰ Improvements Made\n", + "\n", + "The PDF generation has been updated with the following improvements:\n", + "\n", + "### 1. **Fixed List Parsing**\n", + "- Detects pre-numbered list items (e.g., \"1. Document Content Page\")\n", + "- Avoids double-numbering by checking if content already has numbers or bullets\n", + "- Properly handles both ordered lists and bullet lists\n", + "\n", + "### 2. **Reduced Spacing**\n", + "- Title font size: 28 β†’ 20\n", + "- Heading 2 font size: 18 β†’ 16\n", + "- Heading 3 font size: 14 β†’ 13\n", + "- Body text font size: 11 β†’ 10\n", + "- Reduced spacing between elements throughout\n", + "- Tighter line height (leading) for more compact text\n", + "\n", + "### 3. **Professional Styling**\n", + "- Added bold font names for headings\n", + "- Improved color scheme with darker, more professional colors\n", + "- Reduced divider thickness for cleaner look\n", + "- More compact policy description section\n", + "\n", + "The PDFs now have a cleaner, more professional appearance with proper list formatting!\n" + ] + }, + { + "cell_type": "code", + "execution_count": 451, + "metadata": {}, + "outputs": [], + "source": [ + "# πŸ”„ REGENERATE ALL PDFs with enhanced layout (includes policy name & description)\n", + "# Uncomment and run to regenerate all PDFs with the new format:\n", + "# generate_all_pdfs()\n" + ] + }, + { + "cell_type": "code", + "execution_count": 452, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "πŸ“š Generated PDFs in pdfs/ directory:\n", + "------------------------------------------------------------\n", + " βœ“ acceptable_use_policy.pdf\n", + " βœ“ access_control_policy.pdf\n", + " βœ“ asset_management_policy.pdf\n", + " βœ“ business_continuity_policy.pdf\n", + " βœ“ capacity_performance_management.pdf\n", + " βœ“ change_management_policy_.pdf\n", + " βœ“ encryption_cryptographic_control_policy.pdf\n", + " βœ“ endpoint_protection_policy.pdf\n", + " βœ“ executive_summary.pdf\n", + " βœ“ incident_response_policy.pdf\n", + " βœ“ information_protection_policy.pdf\n", + " βœ“ information_security_program.pdf\n", + " βœ“ logging_policy.pdf\n", + " βœ“ physical_security_policy.pdf\n", + " βœ“ privacy_policy.pdf\n", + " βœ“ risk_management_policy_.pdf\n", + " βœ“ secure_development_policy.pdf\n", + " βœ“ security_awareness_training_policy.pdf\n", + " βœ“ third_party_risk_management_policy.pdf\n", + " βœ“ vulnerability_management_policy.pdf\n", + "------------------------------------------------------------\n", + "Total: 20 PDFs\n" + ] + } + ], + "source": [ + "# List all generated PDFs\n", + "import os\n", + "pdf_dir = \"pdfs\"\n", + "if os.path.exists(pdf_dir):\n", + " pdf_files = [f for f in os.listdir(pdf_dir) if f.endswith('.pdf')]\n", + " print(f\"πŸ“š Generated PDFs in {pdf_dir}/ directory:\")\n", + " print(\"-\" * 60)\n", + " for pdf in sorted(pdf_files):\n", + " print(f\" βœ“ {pdf}\")\n", + " print(\"-\" * 60)\n", + " print(f\"Total: {len(pdf_files)} PDFs\")\n", + "else:\n", + " print(f\"❌ No {pdf_dir}/ directory found. Run generate_all_pdfs() first!\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": 453, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "πŸš€ Starting batch PDF generation for 19 policies...\n", + "\n", + "Processing 1/19: Business Continuity Policy\n", + "Generating PDF: pdfs/business_continuity_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/business_continuity_policy.pdf\n", + "\n", + "Processing 2/19: Information Security Program\n", + "Generating PDF: pdfs/information_security_program.pdf\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "βœ… PDF generated successfully: pdfs/information_security_program.pdf\n", + "\n", + "Processing 3/19: Capacity & Performance Management\n", + "Generating PDF: pdfs/capacity_performance_management.pdf\n", + "βœ… PDF generated successfully: pdfs/capacity_performance_management.pdf\n", + "\n", + "Processing 4/19: Change Management Policy \n", + "Generating PDF: pdfs/change_management_policy_.pdf\n", + "βœ… PDF generated successfully: pdfs/change_management_policy_.pdf\n", + "\n", + "Processing 5/19: Encryption & Cryptographic Control Policy\n", + "Generating PDF: pdfs/encryption_cryptographic_control_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/encryption_cryptographic_control_policy.pdf\n", + "\n", + "Processing 6/19: Logging Policy\n", + "Generating PDF: pdfs/logging_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/logging_policy.pdf\n", + "\n", + "Processing 7/19: Physical Security Policy\n", + "Generating PDF: pdfs/physical_security_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/physical_security_policy.pdf\n", + "\n", + "Processing 8/19: Vulnerability Management Policy\n", + "Generating PDF: pdfs/vulnerability_management_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/vulnerability_management_policy.pdf\n", + "\n", + "Processing 9/19: Acceptable Use Policy\n", + "Generating PDF: pdfs/acceptable_use_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/acceptable_use_policy.pdf\n", + "\n", + "Processing 10/19: Access Control Policy\n", + "Generating PDF: pdfs/access_control_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/access_control_policy.pdf\n", + "\n", + "Processing 11/19: Asset Management Policy\n", + "Generating PDF: pdfs/asset_management_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/asset_management_policy.pdf\n", + "\n", + "Processing 12/19: Endpoint Protection Policy\n", + "Generating PDF: pdfs/endpoint_protection_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/endpoint_protection_policy.pdf\n", + "\n", + "Processing 13/19: Incident Response Policy\n", + "Generating PDF: pdfs/incident_response_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/incident_response_policy.pdf\n", + "\n", + "Processing 14/19: Information Protection Policy\n", + "Generating PDF: pdfs/information_protection_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/information_protection_policy.pdf\n", + "\n", + "Processing 15/19: Privacy Policy\n", + "Generating PDF: pdfs/privacy_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/privacy_policy.pdf\n", + "\n", + "Processing 16/19: Risk Management Policy \n", + "Generating PDF: pdfs/risk_management_policy_.pdf\n", + "βœ… PDF generated successfully: pdfs/risk_management_policy_.pdf\n", + "\n", + "Processing 17/19: Secure Development Policy\n", + "Generating PDF: pdfs/secure_development_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/secure_development_policy.pdf\n", + "\n", + "Processing 18/19: Security Awareness & Training Policy\n", + "Generating PDF: pdfs/security_awareness_training_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/security_awareness_training_policy.pdf\n", + "\n", + "Processing 19/19: Third-Party Risk Management Policy\n", + "Generating PDF: pdfs/third_party_risk_management_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/third_party_risk_management_policy.pdf\n", + "\n", + "\n", + "============================================================\n", + "βœ… Batch processing complete!\n", + " Successful: 19\n", + " Failed: 0\n", + " Total: 19\n", + "============================================================\n" + ] + } + ], + "source": [ + "# Run this cell to generate ALL PDFs at once!\n", + "generate_all_pdfs()" + ] + }, + { + "cell_type": "code", + "execution_count": 454, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "πŸš€ Starting batch PDF generation for 19 policies...\n", + "\n", + "Processing 1/19: Business Continuity Policy\n", + "Generating PDF: pdfs/business_continuity_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/business_continuity_policy.pdf\n", + "\n", + "Processing 2/19: Information Security Program\n", + "Generating PDF: pdfs/information_security_program.pdf\n", + "βœ… PDF generated successfully: pdfs/information_security_program.pdf\n", + "\n", + "Processing 3/19: Capacity & Performance Management\n", + "Generating PDF: pdfs/capacity_performance_management.pdf\n", + "βœ… PDF generated successfully: pdfs/capacity_performance_management.pdf\n", + "\n", + "Processing 4/19: Change Management Policy \n", + "Generating PDF: pdfs/change_management_policy_.pdf\n", + "βœ… PDF generated successfully: pdfs/change_management_policy_.pdf\n", + "\n", + "Processing 5/19: Encryption & Cryptographic Control Policy\n", + "Generating PDF: pdfs/encryption_cryptographic_control_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/encryption_cryptographic_control_policy.pdf\n", + "\n", + "Processing 6/19: Logging Policy\n", + "Generating PDF: pdfs/logging_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/logging_policy.pdf\n", + "\n", + "Processing 7/19: Physical Security Policy\n", + "Generating PDF: pdfs/physical_security_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/physical_security_policy.pdf\n", + "\n", + "Processing 8/19: Vulnerability Management Policy\n", + "Generating PDF: pdfs/vulnerability_management_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/vulnerability_management_policy.pdf\n", + "\n", + "Processing 9/19: Acceptable Use Policy\n", + "Generating PDF: pdfs/acceptable_use_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/acceptable_use_policy.pdf\n", + "\n", + "Processing 10/19: Access Control Policy\n", + "Generating PDF: pdfs/access_control_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/access_control_policy.pdf\n", + "\n", + "Processing 11/19: Asset Management Policy\n", + "Generating PDF: pdfs/asset_management_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/asset_management_policy.pdf\n", + "\n", + "Processing 12/19: Endpoint Protection Policy\n", + "Generating PDF: pdfs/endpoint_protection_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/endpoint_protection_policy.pdf\n", + "\n", + "Processing 13/19: Incident Response Policy\n", + "Generating PDF: pdfs/incident_response_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/incident_response_policy.pdf\n", + "\n", + "Processing 14/19: Information Protection Policy\n", + "Generating PDF: pdfs/information_protection_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/information_protection_policy.pdf\n", + "\n", + "Processing 15/19: Privacy Policy\n", + "Generating PDF: pdfs/privacy_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/privacy_policy.pdf\n", + "\n", + "Processing 16/19: Risk Management Policy \n", + "Generating PDF: pdfs/risk_management_policy_.pdf\n", + "βœ… PDF generated successfully: pdfs/risk_management_policy_.pdf\n", + "\n", + "Processing 17/19: Secure Development Policy\n", + "Generating PDF: pdfs/secure_development_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/secure_development_policy.pdf\n", + "\n", + "Processing 18/19: Security Awareness & Training Policy\n", + "Generating PDF: pdfs/security_awareness_training_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/security_awareness_training_policy.pdf\n", + "\n", + "Processing 19/19: Third-Party Risk Management Policy\n", + "Generating PDF: pdfs/third_party_risk_management_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/third_party_risk_management_policy.pdf\n", + "\n", + "\n", + "============================================================\n", + "βœ… Batch processing complete!\n", + " Successful: 19\n", + " Failed: 0\n", + " Total: 19\n", + "============================================================\n" + ] + } + ], + "source": [ + "# Run this cell to generate ALL PDFs at once!\n", + "generate_all_pdfs()" + ] + }, + { + "cell_type": "raw", + "metadata": { + "vscode": { + "languageId": "raw" + } + }, + "source": [ + "## βœ… Success! All 19 Policies Generated\n", + "\n", + "The notebook has been updated to:\n", + "\n", + "1. **Read from source_data.txt** - No more manual JSON copying\n", + "2. **Generate proper filenames** - Each PDF is named after its policy (e.g., `business_continuity_policy.pdf`)\n", + "3. **Batch processing** - Generate all 19 policies with one function call\n", + "4. **Better error handling** - Fixed JSON parsing issues\n", + "5. **Enhanced PDF Layout** - Each PDF now includes:\n", + " - Policy name as the main title\n", + " - Policy description below the title\n", + " - A horizontal divider line before the content\n", + " - Complete policy content with proper formatting\n", + "\n", + "### Next Steps:\n", + "- Check the `pdfs/` directory for all generated PDFs\n", + "- Each PDF contains the complete policy with proper formatting\n", + "- Organization name is set to \"Casper Studios\" by default (customizable)\n", + "- Run the cell above to see a list of all generated PDFs\n", + "- Re-run `generate_all_pdfs()` to regenerate all PDFs with the new layout" + ] + }, + { + "cell_type": "code", + "execution_count": 455, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "πŸš€ Starting batch PDF generation for 19 policies...\n", + "\n", + "Processing 1/19: Business Continuity Policy\n", + "Generating PDF: pdfs/business_continuity_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/business_continuity_policy.pdf\n", + "\n", + "Processing 2/19: Information Security Program\n", + "Generating PDF: pdfs/information_security_program.pdf\n", + "βœ… PDF generated successfully: pdfs/information_security_program.pdf\n", + "\n", + "Processing 3/19: Capacity & Performance Management\n", + "Generating PDF: pdfs/capacity_performance_management.pdf\n", + "βœ… PDF generated successfully: pdfs/capacity_performance_management.pdf\n", + "\n", + "Processing 4/19: Change Management Policy \n", + "Generating PDF: pdfs/change_management_policy_.pdf\n", + "βœ… PDF generated successfully: pdfs/change_management_policy_.pdf\n", + "\n", + "Processing 5/19: Encryption & Cryptographic Control Policy\n", + "Generating PDF: pdfs/encryption_cryptographic_control_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/encryption_cryptographic_control_policy.pdf\n", + "\n", + "Processing 6/19: Logging Policy\n", + "Generating PDF: pdfs/logging_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/logging_policy.pdf\n", + "\n", + "Processing 7/19: Physical Security Policy\n", + "Generating PDF: pdfs/physical_security_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/physical_security_policy.pdf\n", + "\n", + "Processing 8/19: Vulnerability Management Policy\n", + "Generating PDF: pdfs/vulnerability_management_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/vulnerability_management_policy.pdf\n", + "\n", + "Processing 9/19: Acceptable Use Policy\n", + "Generating PDF: pdfs/acceptable_use_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/acceptable_use_policy.pdf\n", + "\n", + "Processing 10/19: Access Control Policy\n", + "Generating PDF: pdfs/access_control_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/access_control_policy.pdf\n", + "\n", + "Processing 11/19: Asset Management Policy\n", + "Generating PDF: pdfs/asset_management_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/asset_management_policy.pdf\n", + "\n", + "Processing 12/19: Endpoint Protection Policy\n", + "Generating PDF: pdfs/endpoint_protection_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/endpoint_protection_policy.pdf\n", + "\n", + "Processing 13/19: Incident Response Policy\n", + "Generating PDF: pdfs/incident_response_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/incident_response_policy.pdf\n", + "\n", + "Processing 14/19: Information Protection Policy\n", + "Generating PDF: pdfs/information_protection_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/information_protection_policy.pdf\n", + "\n", + "Processing 15/19: Privacy Policy\n", + "Generating PDF: pdfs/privacy_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/privacy_policy.pdf\n", + "\n", + "Processing 16/19: Risk Management Policy \n", + "Generating PDF: pdfs/risk_management_policy_.pdf\n", + "βœ… PDF generated successfully: pdfs/risk_management_policy_.pdf\n", + "\n", + "Processing 17/19: Secure Development Policy\n", + "Generating PDF: pdfs/secure_development_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/secure_development_policy.pdf\n", + "\n", + "Processing 18/19: Security Awareness & Training Policy\n", + "Generating PDF: pdfs/security_awareness_training_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/security_awareness_training_policy.pdf\n", + "\n", + "Processing 19/19: Third-Party Risk Management Policy\n", + "Generating PDF: pdfs/third_party_risk_management_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/third_party_risk_management_policy.pdf\n", + "\n", + "\n", + "============================================================\n", + "βœ… Batch processing complete!\n", + " Successful: 19\n", + " Failed: 0\n", + " Total: 19\n", + "============================================================\n" + ] + } + ], + "source": [ + "# Run this cell to generate ALL PDFs at once!\n", + "generate_all_pdfs()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "πŸš€ Starting batch PDF generation for 19 policies...\n", + "\n", + "Processing 1/19: Business Continuity Policy\n", + "Generating PDF: pdfs/business_continuity_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/business_continuity_policy.pdf\n", + "\n", + "Processing 2/19: Information Security Program\n", + "Generating PDF: pdfs/information_security_program.pdf\n", + "βœ… PDF generated successfully: pdfs/information_security_program.pdf\n", + "\n", + "Processing 3/19: Capacity & Performance Management\n", + "Generating PDF: pdfs/capacity_performance_management.pdf\n", + "βœ… PDF generated successfully: pdfs/capacity_performance_management.pdf\n", + "\n", + "Processing 4/19: Change Management Policy \n", + "Generating PDF: pdfs/change_management_policy_.pdf\n", + "βœ… PDF generated successfully: pdfs/change_management_policy_.pdf\n", + "\n", + "Processing 5/19: Encryption & Cryptographic Control Policy\n", + "Generating PDF: pdfs/encryption_cryptographic_control_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/encryption_cryptographic_control_policy.pdf\n", + "\n", + "Processing 6/19: Logging Policy\n", + "Generating PDF: pdfs/logging_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/logging_policy.pdf\n", + "\n", + "Processing 7/19: Physical Security Policy\n", + "Generating PDF: pdfs/physical_security_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/physical_security_policy.pdf\n", + "\n", + "Processing 8/19: Vulnerability Management Policy\n", + "Generating PDF: pdfs/vulnerability_management_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/vulnerability_management_policy.pdf\n", + "\n", + "Processing 9/19: Acceptable Use Policy\n", + "Generating PDF: pdfs/acceptable_use_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/acceptable_use_policy.pdf\n", + "\n", + "Processing 10/19: Access Control Policy\n", + "Generating PDF: pdfs/access_control_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/access_control_policy.pdf\n", + "\n", + "Processing 11/19: Asset Management Policy\n", + "Generating PDF: pdfs/asset_management_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/asset_management_policy.pdf\n", + "\n", + "Processing 12/19: Endpoint Protection Policy\n", + "Generating PDF: pdfs/endpoint_protection_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/endpoint_protection_policy.pdf\n", + "\n", + "Processing 13/19: Incident Response Policy\n", + "Generating PDF: pdfs/incident_response_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/incident_response_policy.pdf\n", + "\n", + "Processing 14/19: Information Protection Policy\n", + "Generating PDF: pdfs/information_protection_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/information_protection_policy.pdf\n", + "\n", + "Processing 15/19: Privacy Policy\n", + "Generating PDF: pdfs/privacy_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/privacy_policy.pdf\n", + "\n", + "Processing 16/19: Risk Management Policy \n", + "Generating PDF: pdfs/risk_management_policy_.pdf\n", + "βœ… PDF generated successfully: pdfs/risk_management_policy_.pdf\n", + "\n", + "Processing 17/19: Secure Development Policy\n", + "Generating PDF: pdfs/secure_development_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/secure_development_policy.pdf\n", + "\n", + "Processing 18/19: Security Awareness & Training Policy\n", + "Generating PDF: pdfs/security_awareness_training_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/security_awareness_training_policy.pdf\n", + "\n", + "Processing 19/19: Third-Party Risk Management Policy\n", + "Generating PDF: pdfs/third_party_risk_management_policy.pdf\n", + "βœ… PDF generated successfully: pdfs/third_party_risk_management_policy.pdf\n", + "\n", + "\n", + "============================================================\n", + "βœ… Batch processing complete!\n", + " Successful: 19\n", + " Failed: 0\n", + " Total: 19\n", + "============================================================\n" + ] + } + ], + "source": [ + "generate_all_pdfs()" + ] + }, + { + "cell_type": "raw", + "metadata": { + "vscode": { + "languageId": "raw" + } + }, + "source": [ + "## Notes and Tips\n", + "\n", + "1. **ReportLab**: This notebook uses ReportLab, which is a pure Python library that doesn't require any system dependencies. It will work out of the box on macOS, Windows, and Linux.\n", + "\n", + "2. **Automatic Filename Generation**: If you don't specify an `output_filename` (set it to `None`), the notebook will automatically use the first heading in your document as the filename, converting it to a filesystem-friendly format (e.g., \"Executive Summary\" becomes \"executive_summary.pdf\").\n", + "\n", + "3. **Template Variables**: The converter automatically replaces:\n", + " - `{{organization}}` with your specified organization name\n", + " - `{{date}}` with the current date or your custom date\n", + "\n", + "4. **Supported Elements**:\n", + " - Headings (h1, h2, h3, etc.)\n", + " - Paragraphs\n", + " - Tables with styled headers and alternating row colors\n", + " - Ordered lists (numbered)\n", + " - Bullet lists (unordered)\n", + " - More elements can be added by extending the node processing logic\n", + "\n", + "5. **Customization**: You can modify the styles in the `create_custom_styles` function to change:\n", + " - Font sizes and colors\n", + " - Spacing between elements\n", + " - Table styling\n", + " - Text alignment (currently set to left-aligned for all text)\n", + "\n", + "6. **Output**: The notebook generates a clean, professional PDF file that's ready for distribution. All PDFs are saved in the `pdfs/` subdirectory to keep your workspace organized\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.6" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/.pdf/pdfs.zip b/.pdf/pdfs.zip new file mode 100644 index 0000000000..0eeb67a5d0 Binary files /dev/null and b/.pdf/pdfs.zip differ diff --git a/.pdf/pdfs/acceptable_use_policy.pdf b/.pdf/pdfs/acceptable_use_policy.pdf new file mode 100644 index 0000000000..a5a188b677 Binary files /dev/null and b/.pdf/pdfs/acceptable_use_policy.pdf differ diff --git a/.pdf/pdfs/access_control_policy.pdf b/.pdf/pdfs/access_control_policy.pdf new file mode 100644 index 0000000000..3f60908ebc Binary files /dev/null and b/.pdf/pdfs/access_control_policy.pdf differ diff --git a/.pdf/pdfs/asset_management_policy.pdf b/.pdf/pdfs/asset_management_policy.pdf new file mode 100644 index 0000000000..cf7fefcb8a Binary files /dev/null and b/.pdf/pdfs/asset_management_policy.pdf differ diff --git a/.pdf/pdfs/business_continuity_policy.pdf b/.pdf/pdfs/business_continuity_policy.pdf new file mode 100644 index 0000000000..b4eac92d2d Binary files /dev/null and b/.pdf/pdfs/business_continuity_policy.pdf differ diff --git a/.pdf/pdfs/capacity_performance_management.pdf b/.pdf/pdfs/capacity_performance_management.pdf new file mode 100644 index 0000000000..4581b5f1cc Binary files /dev/null and b/.pdf/pdfs/capacity_performance_management.pdf differ diff --git a/.pdf/pdfs/change_management_policy_.pdf b/.pdf/pdfs/change_management_policy_.pdf new file mode 100644 index 0000000000..5dca0fb1b6 Binary files /dev/null and b/.pdf/pdfs/change_management_policy_.pdf differ diff --git a/.pdf/pdfs/encryption_cryptographic_control_policy.pdf b/.pdf/pdfs/encryption_cryptographic_control_policy.pdf new file mode 100644 index 0000000000..8655041ace Binary files /dev/null and b/.pdf/pdfs/encryption_cryptographic_control_policy.pdf differ diff --git a/.pdf/pdfs/endpoint_protection_policy.pdf b/.pdf/pdfs/endpoint_protection_policy.pdf new file mode 100644 index 0000000000..34843190db Binary files /dev/null and b/.pdf/pdfs/endpoint_protection_policy.pdf differ diff --git a/.pdf/pdfs/executive_summary.pdf b/.pdf/pdfs/executive_summary.pdf new file mode 100644 index 0000000000..7ed9f07376 Binary files /dev/null and b/.pdf/pdfs/executive_summary.pdf differ diff --git a/.pdf/pdfs/incident_response_policy.pdf b/.pdf/pdfs/incident_response_policy.pdf new file mode 100644 index 0000000000..866fbb57ed Binary files /dev/null and b/.pdf/pdfs/incident_response_policy.pdf differ diff --git a/.pdf/pdfs/information_protection_policy.pdf b/.pdf/pdfs/information_protection_policy.pdf new file mode 100644 index 0000000000..1c82d80066 Binary files /dev/null and b/.pdf/pdfs/information_protection_policy.pdf differ diff --git a/.pdf/pdfs/information_security_program.pdf b/.pdf/pdfs/information_security_program.pdf new file mode 100644 index 0000000000..8751b0921f Binary files /dev/null and b/.pdf/pdfs/information_security_program.pdf differ diff --git a/.pdf/pdfs/logging_policy.pdf b/.pdf/pdfs/logging_policy.pdf new file mode 100644 index 0000000000..5de8994288 Binary files /dev/null and b/.pdf/pdfs/logging_policy.pdf differ diff --git a/.pdf/pdfs/physical_security_policy.pdf b/.pdf/pdfs/physical_security_policy.pdf new file mode 100644 index 0000000000..ebafe6145e Binary files /dev/null and b/.pdf/pdfs/physical_security_policy.pdf differ diff --git a/.pdf/pdfs/privacy_policy.pdf b/.pdf/pdfs/privacy_policy.pdf new file mode 100644 index 0000000000..62e845b695 Binary files /dev/null and b/.pdf/pdfs/privacy_policy.pdf differ diff --git a/.pdf/pdfs/risk_management_policy_.pdf b/.pdf/pdfs/risk_management_policy_.pdf new file mode 100644 index 0000000000..956c78c2d2 Binary files /dev/null and b/.pdf/pdfs/risk_management_policy_.pdf differ diff --git a/.pdf/pdfs/secure_development_policy.pdf b/.pdf/pdfs/secure_development_policy.pdf new file mode 100644 index 0000000000..8edb796145 Binary files /dev/null and b/.pdf/pdfs/secure_development_policy.pdf differ diff --git a/.pdf/pdfs/security_awareness_training_policy.pdf b/.pdf/pdfs/security_awareness_training_policy.pdf new file mode 100644 index 0000000000..b16e825999 Binary files /dev/null and b/.pdf/pdfs/security_awareness_training_policy.pdf differ diff --git a/.pdf/pdfs/third_party_risk_management_policy.pdf b/.pdf/pdfs/third_party_risk_management_policy.pdf new file mode 100644 index 0000000000..35b2e2bdcf Binary files /dev/null and b/.pdf/pdfs/third_party_risk_management_policy.pdf differ diff --git a/.pdf/pdfs/vulnerability_management_policy.pdf b/.pdf/pdfs/vulnerability_management_policy.pdf new file mode 100644 index 0000000000..ad5c73a0f9 Binary files /dev/null and b/.pdf/pdfs/vulnerability_management_policy.pdf differ diff --git a/.pdf/source_data.txt b/.pdf/source_data.txt new file mode 100644 index 0000000000..0d4d162005 --- /dev/null +++ b/.pdf/source_data.txt @@ -0,0 +1,20 @@ +pol_6849cce7f5365c431ff211db,Information Classification & Handling Policy,"This policy ensures all information assets are consistently classified and labeled so they receive protection commensurate with their sensitivity and integrity requirements, reducing the risk of unauthorized disclosure or processing errors.",org_6849cce6df394e5629b1ebd5,published,2025-06-11 18:37:27.218,2025-06-12 00:31:07.035,"{""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Table of Contents\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Document Content Page\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Applicability and Scope\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Controls\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Exceptions Process\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Violations and Disciplinary Action\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Auditor Evidence Artifacts\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Executive Summary\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Casper Studios must classify all data assets within AWS and home-office environments according to defined SOC 2–compliant sensitivity levels. This policy shall ensure labeling and handling controls are implemented and reviewed annually to mitigate unauthorized disclosure and integrity risks. Exceptions must be approved quarterly and violations trigger disciplinary action aligned with HR procedures.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""1. Document Content Page\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""This document contains the Information Classification & Handling Policy sections as listed in the Table of Contents.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""2. Applicability and Scope\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""This policy applies to all Casper Studios employees, contractors, and third‐party processors with access to systems hosting Employee data in AWS, Google Workspace, Slack, Linear, Figma, and Zoom across cloud and home‐office settings.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""3. Controls\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""3.1 Information Classification\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Casper Studios must document and maintain a data classification scheme aligned with SOC 2 CC6.1, categorizing information as Public, Internal, Confidential, or Restricted. The scheme shall be reviewed annually by the Information Security Officer.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""All digital assets in AWS S3 buckets, EC2 volumes, and home‐office devices must be logically labeled with classification metadata as per SOC 2 CC6.2.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Processing integrity labels shall be applied to data inputs and outputs to enforce handling rules under SOC 2 CC6.3.\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""3.2 Handling and Storage Controls\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Confidential or Restricted information must be encrypted at rest and in transit using AES-256 for AWS-hosted data and TLS 1.2+ for Google Workspace and Slack.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Access to classified assets must use role-based access control (RBAC) enforced by IAM policies in AWS and Google Workspace; roles shall be reviewed quarterly.\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""4. Exceptions Process\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Any deviation from classification or handling controls must be requested via the ticketing system, including business justification, compensating controls, and duration. The Information Security Officer and data owner shall review and approve exceptions quarterly; all decisions must be documented.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""5. Violations and Disciplinary Action\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Monitoring tools and audit logs in AWS, Google Workspace, and endpoint management must detect misclassification or mishandling events. Suspected violations must be reported to HR and the Information Security Officer immediately; confirmed violations shall result in disciplinary measures per HR policy, up to termination and legal action.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""6. Auditor Evidence Artifacts\"", \""type\"": \""text\""}]}"",""{\""type\"": \""bulletList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Review minutes of annual classification scheme updates\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Access logs showing RBAC enforcement in AWS IAM\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Exception tickets with approvals and timestamps\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Encryption configuration screenshots for AWS and Google Workspace\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Audit logs indicating disciplinary action and retraining completion\"", \""type\"": \""text\""}]}]}]}""}",yearly,,false,{},none,2025-06-11 21:11:30.611,,false,,frk_pt_683d2716ed82ad63da55dc7f, +pol_6849cce78b1517cb864c9cef,Business Continuity Policy,"This policy ensures the organization can quickly restore critical operations after a disruption by maintaining reliable backups, robust disaster-recovery plans, and validated continuity procedures, thereby reducing the risk of prolonged outages, data loss, and safety hazards.",org_6849cce6df394e5629b1ebd5,published,2025-06-11 18:37:27.218,2025-06-12 00:27:49.220,"{""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Table of Contents\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Document Content Page\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Executive Summary\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Applicability and Scope\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Business Continuity Controls\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Exceptions Process\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Violations and Disciplinary Action\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Audit Evidence\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Executive Summary\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Casper Studios shall maintain business continuity to ensure rapid recovery of critical operations by aligning disaster recovery and backup processes with SOC 2 Trust Services Criteria. The policy addresses cloud-based and remote working environments using AWS infrastructure and company-provided laptops to mitigate risks of data loss, prolonged outages, and service disruption. All controls shall be reviewed quarterly and validated through testing to ensure effectiveness.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Applicability and Scope\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""This policy applies to all Casper Studios employees, contractors, and third parties who design, operate, or support information systems, infrastructure, and facilities in AWS or home offices. It covers backup, disaster recovery, and continuity procedures for AI product development and employee data. Roles include the Information Security Officer, Business Continuity Manager, and IT Operations team.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Business Continuity Controls\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Backup Management (SOC 2 CC7.1)\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Casper Studios must maintain automated backups of all critical system and user data in AWS with an RPO of four hours and an RTO of eight hours.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Backups shall be encrypted in transit and at rest using AES-256 and stored in a separate AWS account.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Backup integrity shall be verified by IT Operations monthly, and verification reports shall be submitted to the Information Security Officer.\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Disaster Recovery Planning (SOC 2 CC7.2)\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""The Business Continuity Manager shall maintain a documented disaster recovery plan defining roles, responsibilities, and procedures for restoring AWS-hosted services.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""The disaster recovery plan shall be tested quarterly via simulated failover exercises involving IT Operations and key stakeholders.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Test results and remediation actions shall be documented and reviewed by senior leadership within two weeks of each exercise.\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Infrastructure Resilience (SOC 2 CC7.3)\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Critical services must run in multi-AZ AWS deployments to ensure high availability and fault tolerance.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Network configurations and security groups shall be reviewed quarterly by IT Operations to validate resilience and secure remote access.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Configuration changes impacting continuity must be approved by the Information Security Officer and tracked in Linear.\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Exceptions Process\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""All exceptions to this policy must be submitted via Slack ticket with business justification, compensating controls, and duration. The Information Security Officer and Business Continuity Manager shall review exceptions within five business days; approved exceptions shall be documented, time-bound, and re-evaluated before expiry.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Violations and Disciplinary Action\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Non-compliance shall be detected through audits, monitoring tools, and incident reviews. Suspected violations must be reported to the Information Security Officer and HR. Confirmed violations shall result in disciplinary action up to termination, immediate access revocation, and possible legal proceedings.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Audit Evidence\"", \""type\"": \""text\""}]}"",""{\""type\"": \""bulletList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""AWS backup logs and snapshot records\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Quarterly disaster recovery test plans, reports, and meeting minutes\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Configuration change approval tickets in Linear\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Exception request and approval records from Slack tickets\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Quarterly policy review and sign-off by Information Security Officer\"", \""type\"": \""text\""}]}]}]}""}",yearly,,false,{},none,2025-06-11 21:10:29.808,,false,,frk_pt_683d29e47d5ca62e4146ff62, +pol_6849cce78e85270ba60f2f9d,Information Security Program,"This policy defines and governs the organization’s information security program to protect the confidentiality, integrity, and availability of information assets and to reduce risks arising from inadequate governance, oversight, or staff awareness.",org_6849cce6df394e5629b1ebd5,published,2025-06-11 18:37:27.218,2025-06-12 00:32:04.866,"{""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Information Security Program\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Executive Summary\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Casper Studios shall implement a SOC 2-aligned Information Security Program that enforces confidentiality, integrity, and availability of employee data, cloud-hosted applications on AWS, and collaboration platforms such as Google Workspace and Slack. Governance roles and control activities shall be defined with annual or quarterly review cycles to ensure oversight and continuous improvement. This policy applies to all personnel, contractors, and systems within Casper Studios’ hybrid cloud and home-office environments.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Table of Contents\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Document Content Page\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Applicability and Scope\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Security Governance Roles\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Policy Compliance Controls\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Management Security Accountability\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Personnel Security\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Exceptions Process\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Violations and Disciplinary Action\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Auditor Evidence Artefacts\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""1. Document Content Page\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""This document comprises the sections listed in the Table of Contents and defines controls, roles, and processes in strict alignment with SOC 2 Trust Services Criteria.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""2. Applicability and Scope\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""This policy shall apply to all Casper Studios employees, contractors, and third parties with access to information systems, data, networks, AWS environments, Google Workspace, and home-office devices. It shall govern use of company-provided laptops and collaboration tools (Slack, Figma, Linear, Zoom). All cloud and on-premise assets storing or processing employee data shall be included in scope.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""3. Security Governance Roles\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Information Security Officer (ISO) shall centrally manage the SOC 2 program, approve controls, and report quarterly to senior management.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Compliance Program Manager shall maintain risk assessments and vendor reviews, ensuring annual updates and remediation tracking.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""People Operations Officer shall enforce pre-employment screening and coordinate security training for all new hires, including AWS and Google Workspace access.\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""4. Policy Compliance Controls\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Staff shall acknowledge the Information Security Program upon onboarding and annually thereafter (T-003, T-002).\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""All policies and procedures shall be published in the company intranet and Google Drive for immediate access (T-022).\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Control activities for AWS access, email authentication (Google Workspace), and Slack integrations shall be documented and enforced through automated configuration management tools.\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""5. Management Security Accountability\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Senior management shall review and approve the Information Security Program, Risk Assessment, and Vendor Risk Assessment annually (T-010 to T-014, T-038).\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Quarterly reports on control effectiveness and remediation status shall be presented to the Board or designate.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Insights from annual reviews shall drive control enhancements and updated SOPs (T-048 to T-052).\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""6. Personnel Security\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Conduct identity verification and background checks before granting AWS or system access (T-016).\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Deliver role-based security and privacy training during onboarding and quarterly refreshers; track completion in HRIS.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Retain training records, screening results, and access logs for a minimum of seven years in compliance with SOC 2 Data Retention requirements.\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""7. Exceptions Process\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Personnel shall submit exception requests in Linear with business justification, compensating controls, duration, and risk assessments. The ISO and Compliance Program Manager shall approve and time-box exceptions, with expiration checks at least quarterly.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""8. Violations and Disciplinary Action\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Automated monitoring shall detect policy violations; incidents shall be reported to the ISO and People Operations. Confirmed violations shall result in corrective action per HR policyβ€”verbal warning, written warning, suspension, or terminationβ€”aligned with severity.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""9. Auditor Evidence Artefacts\"", \""type\"": \""text\""}]}"",""{\""type\"": \""bulletList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Access logs from AWS, Google Workspace, Slack\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Policy acknowledgment tickets in Linear\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Annual review meeting minutes and approval emails\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Training completion certificates and HRIS records\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Exception approval tickets and expiration logs\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Security incident reports and remediation evidence (screenshots, patch logs)\"", \""type\"": \""text\""}]}]}]}""}",yearly,,false,{},none,2025-06-11 21:12:30.494,,false,,frk_pt_683d2315c8fc7f97a083081c, +pol_6849cce75bbfe3d0f3b2d225,Capacity & Performance Management,"This policy ensures critical assets are continuously monitored for capacity, performance, and anomalous behavior so the organization can anticipate demand, prevent service degradation, and defend against denial-of-service or other capacity-related threats.",org_6849cce6df394e5629b1ebd5,published,2025-06-11 18:37:27.218,2025-06-12 00:28:47.161,"{""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Table of Contents\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Document Content Page\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Executive Summary\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""A. Applicability and Scope\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""B. Controls\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""C. Exceptions Process\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""D. Violations and Disciplinary Action\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Auditor Evidence Artefacts\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Document Content Page\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Executive Summary\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""This Capacity & Performance Management policy mandates continuous monitoring and alerting of critical cloud assets to anticipate demand, prevent service degradation, and defend against denial-of-service threats. It aligns with SOC 2 CC7 controls by enforcing measurable review cycles, documented approvals, and incident response triggers. Roles and responsibilities are assigned across the Infrastructure Team, DevOps Lead, and Information Security Officer to ensure accountability and compliance.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""A. Applicability and Scope\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""This policy applies to all Casper Studios employees, contractors, and third parties who design, operate, or support production infrastructure, applications, networks, and AWS resources that handle business-critical workloads in AWS. It covers capacity, performance, and anomaly monitoring activities using company-provided laptops and Google Workspace authentication. Compliance is mandatory for Slack, Linear, Figma, Zoom integrations, and AWS-hosted systems.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""B. Controls\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Resource Capacity Management\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""CloudWatch and custom metrics shall monitor EC2, RDS, Lambda, and ELB every 5 minutes. Alerts for CPU, memory, network, and I/O thresholds must be configured and tested quarterly to detect capacity and performance issues and potential denial-of-service activity.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""The Information Security Officer shall review capacity and performance dashboards monthly and lead quarterly capacity planning sessions to forecast demand, document headroom requirements, and update the capacity planning repository.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""System Administrators shall analyze alerts and logs for anomalous behavior within 15 minutes of trigger, initiate incident response per the Incident Response Policy, and escalate unresolved issues to the DevOps Lead and InfoSec Officer.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""All capacity and performance configurations, alert rules, and monitoring scripts shall undergo annual validation by an independent auditor or via automated compliance checks integrated in the CI/CD pipeline.\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""C. Exceptions Process\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Employees must submit capacity-management exceptions through our ticketing system, providing business justification, compensating controls, and requested duration. The DevOps Lead and Information Security Officer shall approve or deny exceptions within 3 business days. All approved exceptions expire after 30 days and are reviewed by the Security Steering Committee quarterly.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""D. Violations and Disciplinary Action\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Automated monitoring, monthly performance audits, and management reviews shall detect non-compliance. Suspected violations must be reported to the Information Security Officer and HR within 24 hours. Confirmed violations trigger HR disciplinary tiers (verbal warning, written warning, suspension, termination) and immediate access revocation for critical non-compliance.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Auditor Evidence Artefacts\"", \""type\"": \""text\""}]}"",""{\""type\"": \""bulletList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Screenshots of AWS CloudWatch monitoring configurations and alert rules.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Monthly and quarterly capacity and performance reports.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Alert and incident tickets from the ticketing system.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Approval records from the DevOps Lead and Information Security Officer.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Audit logs demonstrating alert triggers, incident responses, and follow-up actions.\"", \""type\"": \""text\""}]}]}]}""}",yearly,,false,{},none,2025-06-11 21:10:40.884,,false,,frk_pt_683d2e212de960aa758a25f5, +pol_6849cce7fc8a2df1f0834dc5,Change Management Policy ,"This policy ensures that all changes to the operating environment are planned, approved, tested, and documented so that system integrity, availability, and accuracy are preserved during and after implementation.",org_6849cce6df394e5629b1ebd5,published,2025-06-11 18:37:27.218,2025-06-12 00:29:25.182,"{""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Table of Contents\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""1. Document Content Page\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""2. Applicability and Scope\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""3. Change Management Controls\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""4. Configuration Management Controls\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""5. Exceptions Process\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""6. Violations and Disciplinary Actions\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""7. Auditor Evidence Artefacts\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Executive Summary\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Casper Studios must ensure that all changes to AWS-hosted infrastructure and applications are systematically managed to preserve system integrity, availability, and accuracy in alignment with SOC 2 Trust Services Criteria. Change Management and Configuration Management controls shall be mandatory, reviewed quarterly and annually, and enforced via automated pipelines and audit logs. Non-compliance triggers defined exception and disciplinary processes, with artifacts retained for SOC 2 audit evidence.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Document Content Page\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Applicability and Scope\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""This Change Management Policy applies to all Casper Studios employees, contractors, and third-party service providers managing or deploying changes in AWS production, staging, and development environments using company-provided laptops and remote access. It covers code, infrastructure, configuration, and system dependencies, with all documentation stored in Google Workspace and Linear. The Information Security Officer shall review this policy annually to ensure ongoing SOC 2 compliance.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Change Management Controls\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Change requests must be submitted via Linear with clear scope, impact assessment, and rollback plan, and automatically logged in AWS CloudTrail (SOC 2 CC6.1).\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""All change requests must be approved by the Change Advisory Board (CAB), and by the Information Security Officer prior to execution (SOC 2 CC6.1).\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Emergency changes must follow a documented emergency change process with retroactive approval and post-implementation review completed within five business days (SOC 2 CC6.1).\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Regression testing must be performed in isolated AWS staging environments and results must be documented in Google Workspace before any production deployment (SOC 2 CC6.3).\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""All deployments shall use automated CI/CD pipelines with IaC (e.g., AWS CloudFormation) and rollback capability to maintain consistency and accuracy (SOC 2 CC6.3).\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""The Information Security Officer shall perform quarterly reviews of change management metrics and compliance dashboards to ensure control effectiveness.\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Configuration Management Controls\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Configuration changes must be defined in standardized IaC templates and tracked via AWS Config (SOC 2 CC7.1).\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""All configuration changes must receive CAB and Information Security Officer authorization, with approvals attached to Linear tickets (SOC 2 CC7.1).\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Configuration baselines shall be defined and approved by the Security Engineer, with quarterly verification to detect drift (SOC 2 CC7.1).\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Unauthorized drift must be detected via AWS Config rules and remediated within 48 hours of detection (SOC 2 CC7.1).\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""The Information Security Officer shall perform an annual review of configuration management reports for completeness and accuracy (SOC 2 CC7.1).\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Exceptions Process\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Exception requests shall be submitted via Linear with business justification, compensating controls, and requested duration. The CAB and Information Security Officer must jointly approve, document, and time-limit each exception, which shall be reviewed at or before expiration. Exception approvals and denials must be recorded in AWS CloudTrail and reviewed quarterly.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Violations and Disciplinary Actions\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Automated audits via AWS CloudTrail, AWS Config, and CI/CD logs must detect non-compliance. Suspected violations must be reported immediately to the Information Security Officer and HR. Confirmed violations shall result in disciplinary actionsβ€”ranging from formal warnings to termination per HR policyβ€”and may include immediate change rollback and remediation within three business days.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Auditor Evidence Artefacts\"", \""type\"": \""text\""}]}"",""{\""type\"": \""bulletList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Linear change request tickets with approval timestamps\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""AWS CloudTrail and AWS Config logs of change events\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Regression and configuration test results documented in Google Workspace\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""CAB meeting minutes and approval records\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Exception request records and time-bound approvals\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Quarterly and annual review reports by the Information Security Officer\"", \""type\"": \""text\""}]}]}]}""}",yearly,,false,{},none,2025-06-11 21:10:50.564,,false,,frk_pt_683d2cbc12b93dc5c8fe3a7d, +pol_6849cce7ecdbe6d141250a4a,Encryption & Cryptographic Control Policy,"This policy establishes requirements for managing encryption, keys, and cryptographic protections to safeguard the confidentiality and integrity of customer and organizational data at rest and in transit.",org_6849cce6df394e5629b1ebd5,published,2025-06-11 18:37:27.218,2025-06-12 00:29:46.979,"{""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Encryption & Cryptographic Control Policy\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Executive Summary\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Casper Studios must enforce encryption and cryptographic controls across all production and non-production environments to maintain confidentiality and integrity of organizational and customer data in alignment with SOC 2 CC6 and CC7 criteria. All encryption keys shall be managed within AWS KMS with automated rotation, strict access controls, and quarterly reviews. Data in transit and at rest must utilize FIPS 140-2 validated algorithms and industry-standard TLS 1.2+ encryption.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Table of Contents\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""1. Document Content Page\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""2. A. Applicability And Scope\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""3. B. Controls\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""4. C. Exceptions Process\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""5. D. Violations And Disciplinary Action\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""6. Auditor Evidence Artefacts\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Document Content Page\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""A. Applicability And Scope\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""This policy applies to all Casper Studios employees, contractors, and third parties who design, implement, or manage cryptographic solutions, keys, databases, and network servicesβ€”whether in AWS production or non-production environments (e.g., us-east-1) or on company-provided laptops in home offices.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Full disk encryption shall be enabled on all company-provided laptops using FIPS 140-2 validated mechanisms (e.g., BitLocker, FileVault) and configured to integrate with Google Workspace authentication for key escrow.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""The Information Security Officer and Data Owner must ensure compliance through quarterly audits and annual policy reviews.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""B. Controls\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""1. Encryption Key Management\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""All cryptographic keys must be generated and stored in AWS Key Management Service (KMS) using FIPS 140-2 validated modules (SOC 2 CC6.1).\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""AWS KMS keys shall be automatically rotated every 90 days, with rotation events recorded in CloudTrail logs and reviewed monthly by the Information Security Officer (SOC 2 CC6.2).\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Access to KMS keys must require IAM roles with least privilege and dual approval by the Information Security Officer and Data Owner (SOC 2 CC7.1).\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Quarterly key inventory and usage reports must be generated and retained for at least one year (SOC 2 CC6.3).\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""2. Secure Data Transfer\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""All in-transit data shall be encrypted using TLS 1.2 or higher with certificates managed by AWS Certificate Manager and renewed at least 30 days before expiration (SOC 2 CC6.5).\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Production databases in AWS RDS must enforce encryption at rest with AES-256 or AWS KMS-managed keys (SOC 2 CC6.4).\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Non-production environments must implement identical encryption configurations as production, unless an exception is approved per Section C (SOC 2 CC6.6).\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""C. Exceptions Process\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Any deviation from this policy must be requested through the Linear ticketing system with business justification, compensating controls, and a defined expiration date. The Information Security Officer and Data Owner shall jointly review and approve or reject each exception, and revalidate approved exceptions quarterly. All exception tickets must be documented and archived for auditor review (SOC 2 CC7.2).\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""D. Violations And Disciplinary Action\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Automated AWS Config checks and monthly security audits shall detect non-compliance; any violations must be reported immediately to the Information Security Officer. Confirmed breaches of cryptographic controls may result in key revocation, access removal, and HR disciplinary action up to termination, in accordance with company policy (SOC 2 CC7.3).\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Auditor Evidence Artefacts\"", \""type\"": \""text\""}]}"",""{\""type\"": \""bulletList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""AWS KMS key policy snapshots and CloudTrail rotation logs\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Monthly IAM access review reports\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""TLS certificate inventory and renewal screenshots\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Linear tickets for exception approvals\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Quarterly key management and audit meeting minutes\"", \""type\"": \""text\""}]}]}]}""}",yearly,,false,{},none,2025-06-11 21:11:01.923,,false,,frk_pt_683d3302c5965789e22c8d7d, +pol_6849cce7e4f284e5003f8596,Logging Policy,"This policy mandates continuous monitoring and logging to detect, evaluate, and respond to security events, thereby protecting the integrity, availability, and reliability of organizational systems and controls.",org_6849cce6df394e5629b1ebd5,published,2025-06-11 18:37:27.218,2025-06-12 00:32:30.890,"{""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Executive Summary\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Casper Studios must maintain continuous logging and monitoring aligned with SOC 2 Trust Services Criteria (CC4.1, CC6.5, CC7.2) using AWS CloudWatch, Splunk, and Google Workspace audit logs. The policy defines mandatory controls for log generation, aggregation, review, and incident detection, with monthly reviews and quarterly audits. It outlines the exception process and disciplinary actions to ensure compliance and readiness for external audits.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Table of Contents\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Document Content Page\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""A. Applicability and Scope\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""B. Controls\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""C. Exceptions Process\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""D. Violations and Disciplinary Action\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""E. Auditor Evidence Artefacts\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""A. Applicability and Scope\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""This policy applies to all Casper Studios employees, contractors, and third parties using company-provided laptops, AWS (us-east-1), Google Workspace, Slack, Linear, Figma, and Zoom. It covers all systems generating, transmitting, storing, or analyzing security-related logs to protect the integrity, availability, and confidentiality of employee data. System Owners, the InfoSec Officer, and the CTO shall enforce these requirements across hybrid and remote environments.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""B. Controls\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Security Monitoring & Detection\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""All AWS and Google Workspace audit logs must be aggregated into AWS CloudWatch Logs and Splunk within one hour of generation (CC6.5).\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""The InfoSec Officer shall review aggregated security event dashboards daily using AWS Security Hub and Slack alerts for anomalous activities (CC7.3).\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""The CTO and Internal Audit Team must analyze log trends quarterly to assess control effectiveness and refine security monitoring (CC4.3).\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""AWS CloudWatch Alarms and AWS Config rules shall detect unauthorized configuration changes and notify System Owners and the InfoSec Officer within two hours (CC6.5).\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Security Logging\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Company-provided laptops and AWS EC2 instances must forward OS and application logs (authentication, system, network) to AWS CloudWatch Logs with a retention period of 90 days (CC4.1).\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Google Workspace Admin audit logs for user activity must be exported daily to AWS S3 (us-east-1) and retained for one year (CC4.1).\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Splunk queries shall detect anomalous login patterns (e.g., more than five failed logins per minute) and alert the on-call Security Engineer via PagerDuty within 15 minutes (CC6.5).\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""All logs shall be encrypted at rest using AES-256 and in transit using TLS 1.2 or higher (CC7.2).\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""C. Exceptions Process\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Requests to deviate from logging requirements must be submitted via a Linear ticket tagged β€œSOC2-Logging”. The ticket shall include business justification, compensating controls, and an expiration date. The InfoSec Officer and System Owner shall review and approve exceptions within five business days, with monthly reviews until closure.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""D. Violations and Disciplinary Action\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Automated and manual log reviews shall detect non-compliance. Suspected violations must be reported to the InfoSec Officer and HR within one business day. Confirmed violations result in disciplinary actionβ€”verbal warning, written warning, suspension, or terminationβ€”and may include access revocation and legal referral as appropriate.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""E. Auditor Evidence Artefacts\"", \""type\"": \""text\""}]}"",""{\""type\"": \""bulletList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""AWS CloudWatch Logs retention reports (screenshots)\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Splunk alert configuration and reports\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Linear tickets for exception requests and approvals\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Quarterly log review meeting minutes\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""AWS S3 bucket audit log export records\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""PagerDuty alert logs\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Internal Audit quarterly report on logging controls\"", \""type\"": \""text\""}]}]}]}""}",yearly,,false,{},none,2025-06-11 21:12:40.454,,false,,frk_pt_683d2de2d5691a4ba424edff, +pol_6849cce7a9030e2a46101e28,Physical Security Policy,Appoint Compliance Program Manager delegated with responsibility for planning and implementing internal control environment,org_6849cce6df394e5629b1ebd5,published,2025-06-11 18:37:27.218,2025-06-12 00:33:36.164,"{""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Document Content Page\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Executive Summary\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Objective\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Scope and Applicability\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Controls\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Exceptions Process\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Violations and Disciplinary Actions\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Auditor Evidence Artifacts\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Executive Summary\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Casper Studios maintains strict physical security controls for its on-site office and remote assets in alignment with SOC 2 Trust Services Criteria. This policy defines objectives, scope, and detailed controls with quarterly and annual review cycles. It assigns clear roles and responsibilities and enforces mandatory processes to mitigate risks of unauthorized access.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Objective\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Establish and enforce physical security measures for Casper Studios’ on-site office and remote home offices, as well as company-provided laptops and AWS infrastructure, to prevent unauthorized access, damage, or interference.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Scope and Applicability\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""This policy applies to all Casper Studios employees, contractors, and third-party personnel accessing company-provided laptops, AWS consoles, on-site office premises, or working from home. It covers physical access to offices, secure handling of keys/badges, device storage, and monitoring systems.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Controls\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Access Rights Management\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""(P-001)\"", \""type\"": \""text\"", \""marks\"": [{\""type\"": \""bold\""}]}, {\""text\"": \"" Casper Studios Compliance Program Manager must maintain an up-to-date list of individuals authorized for physical access to secure office areas, reviewed quarterly.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""(P-002)\"", \""type\"": \""text\"", \""marks\"": [{\""type\"": \""bold\""}]}, {\""text\"": \"" Approval for office badge provisioning must be documented in Google Workspace admin tickets and endorsed by the Information Security Officer.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""(P-003)\"", \""type\"": \""text\"", \""marks\"": [{\""type\"": \""bold\""}]}, {\""text\"": \"" Visitor registration procedures must be performed through the front-desk ticketing system <> with pre-approval from the Facilities Manager.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""(P-004)\"", \""type\"": \""text\"", \""marks\"": [{\""type\"": \""bold\""}]}, {\""text\"": \"" Continuous CCTV monitoring in office common areas must record 24/7 with 90-day retention and prompt alerts to security personnel.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""(P-005)\"", \""type\"": \""text\"", \""marks\"": [{\""type\"": \""bold\""}]}, {\""text\"": \"" Access rights to secure areas must be reviewed and confirmed by the Facilities Manager and InfoSec quarterly.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""(P-006)\"", \""type\"": \""text\"", \""marks\"": [{\""type\"": \""bold\""}]}, {\""text\"": \"" Physical access must be revoked within one business day of role change or termination, via deactivation of badges and remote console access.\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Key and Badge Management\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""(P-007)\"", \""type\"": \""text\"", \""marks\"": [{\""type\"": \""bold\""}]}, {\""text\"": \"" All badges and master keys must be issued and tracked in the asset inventory system, reviewed annually by the Compliance Program Manager.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""(P-008)\"", \""type\"": \""text\"", \""marks\"": [{\""type\"": \""bold\""}]}, {\""text\"": \"" Spare keys must be stored in a locked cabinet with access logs maintained by Facilities.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""(P-009)\"", \""type\"": \""text\"", \""marks\"": [{\""type\"": \""bold\""}]}, {\""text\"": \"" Issued badges and keys must be returned or deactivated when no longer required, verified annually.\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Monitoring and Surveillance\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""(P-010)\"", \""type\"": \""text\"", \""marks\"": [{\""type\"": \""bold\""}]}, {\""text\"": \"" CCTV and badge-entry logs must be correlated daily by the Security Analyst to detect anomalies.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""(P-011)\"", \""type\"": \""text\"", \""marks\"": [{\""type\"": \""bold\""}]}, {\""text\"": \"" Physical security controls (doors, locks, barriers) must be inspected by Facilities quarterly and defects remediated within five business days.\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Remote Workspace Security\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""(P-012)\"", \""type\"": \""text\"", \""marks\"": [{\""type\"": \""bold\""}]}, {\""text\"": \"" Remote employees must secure company-provided laptops in locked storage when unattended and enable screen lock after five minutes of inactivity.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""(P-013)\"", \""type\"": \""text\"", \""marks\"": [{\""type\"": \""bold\""}]}, {\""text\"": \"" Lost or stolen devices must be reported within 24 hours to IT via ticketing system, and devices must be remotely wiped.\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Segregation of Duties\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""(P-014)\"", \""type\"": \""text\"", \""marks\"": [{\""type\"": \""bold\""}]}, {\""text\"": \"" Duties among the Compliance Program Manager, Facilities Manager, and IT Security team must be segregated to prevent conflicts of interest and reviewed annually.\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Exceptions Process\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""All physical security exception requests must be submitted via the ticketing system with business justification, compensating controls, and duration. The Information Security Officer and Facilities Manager shall review and approve exceptions, which are time-bound and reassessed prior to expiration quarterly.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Violations and Disciplinary Actions\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Physical security violations must be reported immediately to the Information Security Officer. Confirmed violations will result in disciplinary action up to termination, and may include immediate access revocation and legal referral.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Auditor Evidence Artifacts\"", \""type\"": \""text\""}]}"",""{\""type\"": \""bulletList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Access review logs (quarterly reports)\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Badge/key issuance and return tickets\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""CCTV footage retention records\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Inspection and remediation logs\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Exception approvals and review meeting minutes\"", \""type\"": \""text\""}]}]}]}""}",monthly,,false,{},gov,2025-06-11 21:12:49.813,,false,,frk_pt_6840747d5056e2862c94d0f5, +pol_6849cce7dae812146478b4f3,Vulnerability Management Policy,"This policy ensures timely identification, evaluation, and remediation of vulnerabilities to prevent exploitation, reduce business impact, and maintain the confidentiality, integrity, and availability of organizational systems and data.",org_6849cce6df394e5629b1ebd5,published,2025-06-11 18:37:27.218,2025-06-12 00:36:33.018,"{""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Executive Summary\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""This Vulnerability Management Policy defines how Casper Studios shall identify, assess, and remediate vulnerabilities within its AWS-hosted infrastructure and company-provided laptops. It aligns with SOC 2 Security and Monitoring controls by mandating monthly automated scans, quarterly reviews, and annual penetration tests. All remediation activities must be tracked in Linear and reviewed by the Security Lead quarterly.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Table of Contents\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Document Content Page\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Executive Summary\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Scope and Applicability\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Controls\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Exceptions Process\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Violations and Disciplinary Action\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Auditor Evidence Artefacts\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Scope and Applicability\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""This policy applies to all Casper Studios employees, contractors, and third-party service providers who design, administer, or use cloud-hosted infrastructure on AWS and company-provided laptops in home offices. All systems authenticating via Google Workspace or email/password must be included. Data in scope includes employee data and system metadata.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Controls\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Vulnerability Identification and Assessment\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Casper Studios shall perform automated vulnerability scans of AWS environments and company-provided laptops at least monthly \"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""The Security Lead shall review scan results and prioritize findings by CVSS score within five business days of scan completion.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Annual penetration testing of public-facing systems shall be conducted by a qualified third party, with a report delivered to the InfoSec Officer.\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Remediation Tracking and Reporting\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""All identified vulnerabilities must be entered into Linear within one business day and assigned an SLA: critical within one week, high within two weeks, medium within one month, low within one quarter.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Remediation progress shall be reviewed by the Vulnerability Management Lead quarterly and reported to executive management.\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Configuration and Patch Management\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Company-provided laptops shall receive OS and application patches within one month of vendor release during monthly maintenance windows.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""AWS AMIs and container images shall be rebuilt and redeployed with security updates at least quarterly.\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Exceptions Process\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Exceptions to this policy must be requested via the Linear ticketing system with business justification, compensating controls, and duration. The InfoSec Officer and Vulnerability Management Lead shall jointly approve and time-limit exceptions. All exceptions shall be reviewed quarterly.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Violations and Disciplinary Action\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Non-compliance is detected via automated scan reports, patch status dashboards, and security audits. Violations shall be reported to the InfoSec Officer and HR. Confirmed violations may result in verbal or written warnings, suspension, termination, and immediate access revocation.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Auditor Evidence Artefacts\"", \""type\"": \""text\""}]}"",""{\""type\"": \""bulletList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Monthly vulnerability scan logs\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Annual penetration test reports\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Linear remediation tickets with SLA timestamps\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Exception approval records\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Patch compliance and deployment screenshots\"", \""type\"": \""text\""}]}]}]}""}",yearly,,false,{},none,2025-06-11 21:14:35.944,,false,,frk_pt_683d3362f2059bd8f1d493bd, +pol_6849cce7d9321656faa67d5d,Acceptable Use Policy," Define acceptable behaviour and technology usage so employees safeguard organisational assets, uphold confidentiality, integrity and availability, and foster a respectful work environment.",org_6849cce6df394e5629b1ebd5,published,2025-06-11 18:37:27.218,2025-06-12 00:21:14.880,"{""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Executive Summary\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Casper Studios’ Acceptable Use Policy defines mandatory behaviours and controls to protect the confidentiality, integrity, and availability of AWS-hosted and corporate assets in strict alignment with SOC 2 Trust Services Criteria.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""This policy applies to all employees, contractors, interns, and third parties accessing Casper Studios systems (Google Workspace, Slack, Linear, Figma, Zoom) via company-provided laptops or enrolled BYOD in home-office or hybrid work settings.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Controls are monitored quarterly, and the Information Security Officer shall review this policy annually.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Table of Contents\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Document Content Page\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Scope and Applicability\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Controls\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Policy Acknowledgement\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Exceptions Process\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Violations and Disciplinary Action\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Auditor Evidence Artefacts\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""1. Scope and Applicability\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""This policy shall apply to all Casper Studios employees, contractors, interns, and third parties who access or use company systems, networks, devices or data in any location (office, home-office, or hybrid) from onboarding through off-boarding.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""2. Controls\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""2.1 Acceptable Use Standards\"", \""type\"": \""text\""}]}"",""{\""type\"": \""bulletList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Users shall access company resources only with unique, organization-issued credentials protected by MFA (CC6.1); credentials must never be shared or left unattended.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Endpoints shall install security patches within 7 days of release, run approved endpoint protection (enable full-disk encryption, and auto-lock after no more than 5 minutes idle (CC7.2).\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Sensitive data shall be stored only in AWS approved services (e.g., S3, RDS) and transmitted via encrypted channels (TLS 1.2+ or corporate VPN) in accordance with CC3.4.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""On untrusted networks users must connect via corporate VPN; creating unauthorized personal hotspots or using network-scanning tools is prohibited (CC6.4).\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Prohibited activities include using pirated software, accessing illegal content, harassment, crypto-mining, personal commercial ventures, or any actions that degrade service or security (CC6.3).\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""All activity on corporate assets may be logged and monitored continuously (CC7.3); users shall have no expectation of personal privacy.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Personal devices accessing company data must enroll in MDM and are subject to remote wipe upon termination or suspected compromise (CC6.1).\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""3. Policy Acknowledgement\"", \""type\"": \""text\""}]}"",""{\""type\"": \""bulletList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""All new personnel shall acknowledge this policy during onboarding via Google Workspace forms (CC2.3).\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""All personnel shall re-acknowledge annually or upon significant policy changes to reinforce accountability.\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""4. Exceptions Process\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Employees shall request acceptable-use exceptions through Linear ticketing with business justification, compensating controls and duration; the Information Security Officer and HR Manager shall jointly approve, document, time-limit, and review each exception at or before expiration (CC5.2).\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""5. Violations and Disciplinary Action\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Automated monitoring and quarterly audits shall detect non-compliance; suspected violations are reported to the Information Security Officer and HR Manager for investigation. Confirmed violations shall follow HR disciplinary tiersβ€”verbal warning, written warning, suspension, or terminationβ€”and may include immediate access revocation or legal action (CC7.4).\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""6. Auditor Evidence Artefacts\"", \""type\"": \""text\""}]}"",""{\""type\"": \""bulletList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Access logs and MFA reports\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Patch and encryption deployment tickets\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Data storage configuration screenshots\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Annual policy acknowledgement records\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Exception approval tickets\"", \""type\"": \""text\""}]}]}]}""}",yearly,,false,{},none,2025-06-11 21:08:19.383,,false,,frk_pt_683d2865c3f65743f7c7a350, +pol_6849cce77e13d667d7d7a83f,Access Control Policy,"This policy establishes controls that limit access to information systems and data to authorized users, thereby reducing the risk of unauthorized disclosure, alteration, or disruption of critical services.",org_6849cce6df394e5629b1ebd5,published,2025-06-11 18:37:27.218,2025-06-12 00:26:05.056,"{""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Table of Contents\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Document Content Page\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Applicability and Scope\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Controls\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Exceptions Process\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Violations and Disciplinary Action\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Auditor Evidence Artefacts\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Executive Summary\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""This Access Control Policy for Casper Studios establishes mandatory controls over logical and physical access to AWS environments, company-provided laptops, and cloud applications (Google Workspace, Slack, Linear, Figma, Zoom) in strict alignment with SOC 2 Trust Services Criteria. It defines roles (Information Security Officer, System Owners, HR), enforces least-privilege and MFA via Google Workspace and AWS IAM, and mandates quarterly and annual reviews. All exceptions, monitoring, and disciplinary processes are documented and auditable.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""2. Applicability and Scope\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""This policy applies to all Casper Studios employees, contractors, and third-party vendors using company-provided laptops, home offices, or office workstations to access production consoles, databases, applications, networks, and AWS cloud resources. It covers authentication via Google Workspace, email/password, and integration with Slack, Linear, Figma, and Zoom. All access management activities must align with SOC 2 CC6.1 (Logical and Physical Access Controls) and CC7.1 (Change Management).\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""3. Controls\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""3.1 Access Rights\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""The Information Security Officer shall review and approve the list of IAM users and roles with production access in AWS quarterly.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""System Owners must approve access requests based on documented job functions and least-privilege principle; all requests are tracked in Linear tickets.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""User registration and authorization procedures shall be maintained and reviewed annually.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Continuous monitoring of AWS CloudTrail logs must alert the security team to unauthorized or anomalous access within 24 hours.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Access shall be revoked or disabled within one business day upon role change or termination, as recorded in HR ticketing.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Production database (RDS) privileges must be restricted to roles with documented business need and reviewed annually.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Administrative privileges are limited to the Information Security Officer and designated System Owners and reviewed quarterly.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""All privilege escalation events must be approved in Linear and reviewed during quarterly access audits.\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""3.2 Credential Management\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Passwords must be at least 12 characters with complexity rules enforced via Google Workspace password policy.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Multi-factor authentication is mandatory for all Google Workspace and AWS IAM users without exception.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Credentials shall be rotated every 180 days and immediately upon suspected compromise.\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""3.3 Remote-Work Security\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""All remote devices must run a managed Endpoint Detection and Response agent, updated monthly.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Company laptops shall auto-lock after 5 minutes of inactivity in home-office or public environments.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Remote device compliance scans must occur daily via MDM and reported to the security team.\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""3.4 Segregation of Duties\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Responsibilities for code deployment, change approval, and production access shall be separated between development, operations, and security teams.\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""4. Exceptions Process\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""All access exceptions must be requested through Linear, include business justification, compensating controls, and duration (max 30 days), and be approved jointly by the Information Security Officer and CTO. Exception tickets shall be reviewed at expiration or quarterly, whichever is sooner.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""5. Violations and Disciplinary Action\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Automated monitoring via CloudTrail and periodic audits shall detect violations; alerts are sent to the Information Security Officer and HR. Confirmed violations result in immediate access revocation and disciplinary actions per HR policy, up to termination.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""6. Auditor Evidence Artefacts\"", \""type\"": \""text\""}]}"",""{\""type\"": \""bulletList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Quarterly AWS IAM access review reports\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""AWS CloudTrail logs of access events\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Linear tickets for access provisioning, modification, and exception approvals\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Google Workspace admin console screenshots showing MFA enforcement\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""MDM compliance scan reports for remote devices\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Minutes from quarterly access and exception review meetings\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""HR termination tickets confirming access removal\"", \""type\"": \""text\""}]}]}]}""}",yearly,,false,{},none,2025-06-11 21:09:50.273,,false,,frk_pt_683d2375aef9512864fe62bb, +pol_6849cce74bebaa226ebcfb81,Asset Management Policy,"This policy ensures that all organizational assets are identified, assigned ownership, and protected according to their value and risk, reducing the likelihood of loss, misuse, or inadequate accountability.",org_6849cce6df394e5629b1ebd5,published,2025-06-11 18:37:27.218,2025-06-12 00:26:56.955,"{""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Document Content Page\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Document Content Page\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Executive Summary\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Applicability and Scope\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Asset Management Controls\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Exceptions Process\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Violations and Disciplinary Action\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Auditor Evidence Artefacts\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Executive Summary\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""This Asset Management Policy defines the identification, classification, ownership, and protection of Casper Studios assets to meet SOC 2 security criteria. It mandates quarterly inventory reviews, assigns clear roles and responsibilities, and enforces mandatory controls across AWS, company laptops, and cloud services. Compliance will be audited annually, with evidence artifacts maintained for verification.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Applicability and Scope\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""This policy applies to all Casper Studios employees, contractors, and third parties who handle organizational assets, including AWS resources, company-provided laptops used in home offices, Google Workspace accounts, and related cloud services. It covers physical hardware, virtual machines, data repositories, and SaaS applications. All assets must adhere to SOC 2 Trust Service Criteria for Security (CC1, CC6, CC7).\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Asset Management Controls\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Asset Inventory\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""CS-AM-1: The IT Administrator must maintain an up-to-date inventory of all organizational assets (physical and virtual) in AWS (e.g., EC2, S3) and laptops in a centralized CMDB. This inventory shall be reviewed quarterly.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""CS-AM-2: The Asset Owner must classify assets according to SOC 2 sensitivity levels (e.g., Confidential, Internal) upon acquisition and record the classification in the inventory. Classifications shall be validated quarterly.\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Asset Ownership\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""CS-AM-3: The Information Security Officer must assign and document ownership responsibilities for each asset, specifying owner role, responsibilities, and contact information. Ownership assignments shall be validated quarterly.\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Asset Protection\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""CS-AM-4: The IT Administrator must implement logical and physical controls (e.g., disk encryption on laptops, AWS IAM policies, VPC configurations) aligned with SOC 2 CC6 and CC7 to protect assets based on classification. Controls shall be tested annually.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""CS-AM-5: The IT Administrator shall ensure automated monitoring for asset integrity and unauthorized changes, with alerts configured in AWS CloudWatch and Google Workspace logs. Alerts and logs shall be reviewed monthly.\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Exceptions Process\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Employees must submit asset-related exception requests via Linear ticketing, including business justification, compensating controls, and requested duration. The Information Security Officer and Asset Owner shall approve, document, and time-limit each exception in the ticket, and review at or before expiry on a quarterly basis. Exceptions must be revoked immediately if expiration occurs without formal renewal.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Violations and Disciplinary Action\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Automated monitoring and quarterly audits shall detect non-compliance with this policy. Suspected violations must be reported to the Information Security Officer and HR. Confirmed violations shall result in disciplinary actionsβ€”ranging from written warnings to terminationβ€”and may include revocation of access and legal action.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Auditor Evidence Artefacts\"", \""type\"": \""text\""}]}"",""{\""type\"": \""bulletList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Asset inventory export from CMDB with timestamps.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Quarterly review meeting minutes signed by the Information Security Officer.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Linear tickets for asset exception requests with approvals.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""AWS CloudWatch and Google Workspace audit logs.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Screenshots of IAM policy configurations and encryption settings.\"", \""type\"": \""text\""}]}]}]}""}",yearly,,false,{},none,2025-06-11 21:10:17.381,,false,,frk_pt_683d23ceaf2c5e4e8933b0ae, +pol_6849cce7a0e89cf7e09b74a6,Endpoint Protection Policy,"This policy safeguards the organization’s information assets by ensuring endpoints are protected against malware, encrypted against unauthorized access, and accurately inventoried, thereby minimizing the risk of compromise, data loss, or service disruption.",org_6849cce6df394e5629b1ebd5,published,2025-06-11 18:37:27.218,2025-06-12 00:30:15.817,"{""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Executive Summary\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""This Endpoint Protection Policy defines mandatory controls for Casper Studios’ cloud and home-office endpoints to meet SOC 2 requirements. It assigns roles, specifies measurable review cycles, and leverages AWS us-east-1 and Google Workspace integrations to protect employee data. All unresolved configuration details are marked for review.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Table of Contents\"", \""type\"": \""text\""}]}"",""{\""type\"": \""bulletList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""1. Document Content Page\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""2. Applicability and Scope\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""3. Controls\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""4. Exceptions Process\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""5. Violations and Disciplinary Action\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""6. Auditor Evidence Artefacts\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""2. Applicability and Scope\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""This policy applies to all Casper Studios employees, contractors, and third parties configuring, using, or managing company-provided laptops, home-office desktops, and AWS EC2 instances in us-east-1 that access, store, or process employee data.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""3. Controls\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""3.1 Malware Protection\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""All endpoints accessing AWS-hosted applications or storing employee data must have approved anti-malware software (e.g., Sophos Endpoint Protection agent version) installed, configured, and updated daily; the Security Administrator shall verify signature updates quarterly.\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""3.2 Inventory and Encryption\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""The IT Manager must maintain an automated endpoint inventory via AWS Config and a CMDB, reviewing asset records quarterly for accuracy and completeness.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""All company-provided laptops and EBS volumes shall be encrypted with AES-256 using AWS KMS keys managed in us-east-1; compliance scans shall run monthly.\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""3.3 Endpoint Security Administration\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""The Security Administrator shall document and maintain endpoint configuration procedures in the internal Security Wiki, reviewing them annually.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Encryption keys and anti-malware policies must be centrally managed via AWS IAM and Google Workspace APIs, with access reviewed quarterly by Executive Management.\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""4. Exceptions Process\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Employees must request endpoint exceptions through Linear tickets, including business justification, compensating controls, and duration capped at 30 days. The Information Security Officer and IT Manager shall jointly approve, document, and time-limit each exception; all exceptions shall be re-evaluated at expiration.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""5. Violations and Disciplinary Action\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Automated monitoring via AWS CloudWatch and intrusion detection systems shall detect non-compliance. Suspected violations must be reported to the Information Security Officer and HR within 24 hours. Confirmed violations will trigger HR disciplinary tiers (verbal warning through termination) and may include immediate access revocation or device quarantine.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""6. Auditor Evidence Artefacts\"", \""type\"": \""text\""}]}"",""{\""type\"": \""bulletList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""AWS Config export reports and KMS key configuration screenshots\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Daily anti-malware update logs and quarterly verification records\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""CMDB asset inventory exports with quarterly review annotations\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Linear exception request and approval tickets\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Incident reports and HR disciplinary action logs\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Minutes from quarterly security review meetings\"", \""type\"": \""text\""}]}]}]}""}",yearly,,false,{},none,2025-06-11 21:11:12.444,,false,,frk_pt_683d2b1405adc4b3773db2c6, +pol_6849cce72221f649077ddbbc,Incident Response Policy,"This policy ensures the organization can rapidly detect, report, and respond to information-security incidents to minimize business impact, fulfill legal obligations, and protect stakeholder interests.",org_6849cce6df394e5629b1ebd5,published,2025-06-11 18:37:27.218,2025-06-12 00:30:44.405,"{""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Incident Response Policy\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Executive Summary\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Casper Studios must rapidly detect, report, and respond to security incidents affecting its AWS-hosted applications and home-office environments to minimize business impact and maintain SOC 2 compliance. The policy applies to all employees, contractors, and third-party vendors using Google Workspace and company-provided laptops to access corporate systems. The Information Security Officer shall review the policy quarterly and perform an annual audit to ensure continued alignment with SOC 2 CC7 and CC8 controls.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Table of Contents\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Document Content Page\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Applicability and Scope\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Controls\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Exceptions Process\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Violations and Disciplinary Action\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Auditor Evidence Artefacts\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Document Content Page\"", \""type\"": \""text\""}]}"",""{\""type\"": \""bulletList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Policy Title: Incident Response Policy\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Owner: Information Security Officer\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Review Cycle: Quarterly technical review; Annual SOC 2 audit\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Applicability and Scope\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""This policy applies to all Casper Studios employees, contractors, and third-party vendors accessing systems in AWS or remote environments via Google Workspace, Figma, Slack, and Zoom on company-provided laptops. It covers the full incident lifecycleβ€”from detection through closureβ€”for events impacting the confidentiality, integrity, or availability of employee data. The Incident Response Lead must review scope applicability quarterly.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Controls\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Incident Detection and Reporting\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Casper Studios must deploy and maintain AWS CloudWatch, GuardDuty, and Slack-integrated alerts to detect anomalies and security events in real time (SOC 2 CC7.2).\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Employees and contractors must report suspected incidents via the internal incident-ticketing system and to ir@casperstudios.xyz within 1 hour of detection (SOC 2 CC7.2).\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""The Incident Response Lead shall review all alerts daily and escalate high-severity events to the Information Security Officer within 2 hours.\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Incident Response and Recovery\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Casper Studios shall maintain a documented AWS-centric incident-response runbook defining roles (Incident Response Lead, Engineering Team, Information Security Officer) and response steps (SOC 2 CC7.3).\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""The Incident Response team must contain and eradicate incidents within 24 hours, performing root-cause analysis and recovery actions on cloud and home-office endpoints.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""The Information Security Officer shall notify stakeholders and customers per breach notification guidelines within 72 hours of incident confirmation (SOC 2 CC7.4).\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Post-incident reviews must be conducted within 10 business days and documented for continuous improvement; lessons learned shall be presented quarterly to executive management.\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Exceptions Process\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Employees must submit exception requests to the incident-ticketing system with justification, compensating controls, and duration. The Information Security Officer and Incident Response Lead shall approve or reject exceptions within 5 business days. Exceptions must be reviewed at expiration and no later than 30 days.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Violations and Disciplinary Action\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Compliance monitoring tools shall detect policy deviations. Suspected violations must be reported to the Information Security Officer and HR within 24 hours. Confirmed violations shall result in disciplinary actions per company policy, up to termination and legal action; access privileges will be revoked immediately.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Auditor Evidence Artefacts\"", \""type\"": \""text\""}]}"",""{\""type\"": \""bulletList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""AWS GuardDuty and CloudWatch logs\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Incident ticket records and timelines\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Approval records from the ticketing system\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Post-incident review reports and meeting minutes\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Stakeholder notification logs and screenshots\"", \""type\"": \""text\""}]}]}]}""}",yearly,,false,{},none,2025-06-11 21:11:21.927,,false,,frk_pt_683d333874c936f38d84fecc, +pol_6849cce7a863abef5ab0cda8,Information Protection Policy,"This policy preserves the confidentiality, integrity, and availability of organizational information by establishing clear requirements for data retention and secure disposal, network protections, and strong cryptographic safeguards for data at rest and in transit.",org_6849cce6df394e5629b1ebd5,published,2025-06-11 18:37:27.218,2025-06-12 00:31:25.425,"{""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Table of Contents\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""1. Document Content Page\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""2. Executive Summary\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""3. Applicability and Scope\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""4. Controls\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""5. Exceptions Process\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""6. Violations and Disciplinary Action\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""7. Auditor Evidence Artefacts\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Executive Summary\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Casper Studios’ Information Protection Policy establishes mandatory requirements to preserve the confidentiality, integrity, and availability of employee and business data across AWS-hosted systems and company-provided laptops in compliance with SOC 2 Security and Confidentiality criteria. The policy mandates defined retention and disposal schedules, network segmentation with deny-by-default firewall rules, and AWS KMS-backed encryption in transit and at rest, all subject to quarterly reviews. All personnel, contractors, and third-party administrators shall comply with these controls, with any deviations managed through a documented exception process.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Document Contents\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Applicability and Scope\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""This policy applies to all Casper Studios employees, contractors, and third-party service providers who access, create, store, transmit, or manage organizational information in AWS (e.g., us-east-1, us-west-2), Google Workspace, Slack, Linear, Figma, or Zoom on company-provided laptops or authorized home-office devices. It covers all environmentsβ€”production and non-productionβ€”as well as network infrastructure, databases, and endpoints. Roles subject to this policy include but are not limited to system administrators, developers, HR personnel, and third-party auditors.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Controls\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Data Retention and Disposal\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Retention periods must be defined per data classification (e.g., employee records retained for seven years) and documented in Linear, subject to quarterly review by the Information Security Officer. AWS S3 lifecycle policies or equivalent automated mechanisms shall enforce secure deletion in accordance with NIST 800-88 guidelines. Disposal process efficacy must be validated quarterly and logged in Google Workspace audit logs.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Media decommissioning (e.g., AWS EBS volumes, local disk images) must employ cryptographic wipe or overwriting procedures; evidence of sanitization shall be retained for audit and reviewed annually.\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Network Security\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""All AWS workloads must reside in VPC subnets with Security Groups and Network ACLs configured with deny-by-default rules; changes shall be approved via Slack #security-alerts and reviewed quarterly. Home-office connections must use company VPN and local firewall policies matching deny-by-default principles.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Every host (production and non-production) running on AWS or company laptops must enforce a host-based firewall (e.g., ufw, Windows Defender Firewall) with deny-by-default and allow-list rules; configurations are to be audited quarterly.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Communications protection controls shall require TLS 1.2 or higher for all internal and external traffic, using AWS Certificate Manager for certificate management, reviewed quarterly for expiration and cipher strength.\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Data Transmission Security\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""All data in transit across public or untrusted networks must use industry-standard encryption (HTTPS/TLS 1.2+); configuration must be captured in Figma network diagrams and reviewed quarterly.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Production and non-production databases handling employee data must encrypt data at rest using AWS KMS-managed keys; key rotation shall occur annually and be logged in AWS CloudTrail.\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Exceptions Process\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Personnel must submit exception requests in Linear, providing business justification, compensating controls, and proposed duration. The Information Security Officer and relevant data owner shall jointly review and approve exceptions, documenting decisions and timing in Slack #security-exceptions. All exceptions must be reviewed at or before expiration, no less frequently than quarterly.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Violations and Disciplinary Action\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Continuous monitoring via AWS CloudWatch, Google Workspace audit logs, and Slack integrations shall detect non-compliance. Suspected violations must be reported immediately to the Information Security Officer and HR for investigation. Confirmed violations shall invoke disciplinary proceduresβ€”verbal warning, written warning, suspension, or terminationβ€”aligned with severity and may include immediate access revocation.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Auditor Evidence Artefacts\"", \""type\"": \""text\""}]}"",""{\""type\"": \""bulletList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Retention and deletion logs (AWS S3 lifecycle, NIST 800-88 reports)\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Security Group and firewall configuration screenshots\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""AWS CloudTrail and CloudWatch logs\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""TLS certificate inventories and renewal tickets\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Exception request tickets and approval records\"", \""type\"": \""text\""}]}]}]}""}",yearly,,false,{},none,2025-06-11 21:12:16.557,,false,,frk_pt_683d2f8cfdf08987e67a2dff, +pol_6849cce744fb6ee9379c3e77,Privacy Policy,"This policy embeds privacy-by-design principles across all business processes to protect personal data, meet global regulatory requirements, and maintain stakeholder trust.",org_6849cce6df394e5629b1ebd5,published,2025-06-11 18:37:27.218,2025-06-12 00:34:11.723,"{""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Table of Contents\"", \""type\"": \""text\""}]}"",""{\""type\"": \""bulletList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""1. Document Content Page\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""2. Applicability & Scope\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""3. Privacy Controls (SOC 2 Aligned)\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""4. Exceptions Process\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""5. Violations & Disciplinary Action\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""6. Auditor Evidence Artefacts\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Executive Summary\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Casper Studios shall embed privacy-by-design aligned with SOC 2 Privacy and Confidentiality Criteria across all processes to safeguard employee data and customer PII. The policy defines mandatory governance, data inventory, access controls, retention, vendor management, incident response, and monitoring controls for AWS-hosted applications and remote work. This policy shall be reviewed quarterly by the Privacy Officer and Information Security Manager.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Document Content Page\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""1. Applicability & Scope\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""This policy applies to all Casper Studios employees, contractors, and third parties collecting, processing, or storing employee or customer data on AWS, Google Workspace, Slack, Linear, Figma, or Zoom, in office and home-office environments.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""2. Privacy Controls (SOC 2 Aligned)\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""2.1 Governance & Accountability\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Casper Studios shall appoint a Privacy Officer and an Information Security Manager to oversee SOC 2 control implementation and compliance; responsibilities and delegations shall be documented and reviewed quarterly.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""2.2 Data Inventory & Classification\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Maintain an up-to-date inventory of employee and customer data categories in AWS Config and Google Workspace; classification and usage purposes shall be validated quarterly.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""2.3 Data Lifecycle & Retention\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Define retention periods for each data category; data shall be archived or securely disposed in AWS S3 per schedule and reviewed annually.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""3.4 Access & Authorization\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""All access to privacy-related systems (AWS, Google Workspace, Slack) must require MFA and be provisioned via formal ticketing with manager approval; access rights shall be reviewed quarterly.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""3.5 Vendor & Third-Party Management\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Maintain a list of third-party processors with privacy clauses in contracts; conduct privacy-risk assessments annually and require notifications of unauthorized disclosures within 24 hours.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""3.6 Incident Response & Monitoring\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Monitor AWS CloudTrail, Google Workspace logs, and Slack audit logs daily; the incident response team shall investigate and notify affected parties within 24 hours of breach detection and conduct post-incident reviews quarterly.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""4. Exceptions Process\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Exceptions shall be requested via the ticketing system with business justification and compensating controls; the Privacy Officer and Information Security Manager shall jointly approve and time-limit each exception and review before expiration.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""5. Violations & Disciplinary Action\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Suspected policy violations must be reported to the Privacy Officer and HR; confirmed violations shall follow HR disciplinary procedures (verbal warning, written warning, suspension, termination) and may involve regulatory notification.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""6. Auditor Evidence Artefacts\"", \""type\"": \""text\""}]}"",""{\""type\"": \""bulletList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Access review reports from ticketing system.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""AWS CloudTrail and Google Workspace activity logs.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Privacy-risk assessment records.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Incident and breach response tickets and post-incident reports.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Vendor privacy compliance assessment documentation.\"", \""type\"": \""text\""}]}]}]}""}",yearly,,false,{},none,2025-06-11 21:13:39.928,,false,,frk_pt_683d352ed697c40275349026, +pol_6849cce7bd491e0bccb5eafa,Risk Management Policy ,"This policy establishes a structured risk management process to identify, analyze, and treat threats that could jeopardize the organization’s ability to meet its security commitments and business objectives.",org_6849cce6df394e5629b1ebd5,published,2025-06-11 18:37:27.218,2025-06-12 00:34:40.336,"{""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Risk Management Policy\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Executive Summary\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Casper Studios shall identify, assess, and mitigate information security risks to ensure confidentiality, integrity, and availability in alignment with SOC 2 risk management criteria. The Information Security Officer and Risk Owner shall perform risk assessments at least annually and update risk treatment plans quarterly, covering AWS deployments and home office environments. Exceptions require documented approval and regular reviews to maintain control effectiveness.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""1. Document Content Page\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Document Content Page\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""A. Applicability and Scope\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""B. SOC 2 Control Mapping\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""C. Exceptions Process\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""D. Violations and Disciplinary Action\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Auditor Evidence Artefacts\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""2. A. Applicability and Scope\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""This policy shall apply to all Casper Studios employees, contractors, and third-party service providers accessing AWS-hosted applications and employee data from company-provided laptops or home offices. Covered tools include Google Workspace, Slack, Linear, Figma, and Zoom in all regions. The Information Security Officer and Risk Owner shall enforce and review applicability quarterly.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""3. B. SOC 2 Control Mapping\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Risk Assessment and Treatment\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""(TSC.CC3.1) Casper Studios shall identify and document information security risks to its AWS-hosted services, home-office environments, and Google Workspace assets at least annually.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""(TSC.CC3.4) The Information Security Officer shall assess each risk’s likelihood and impact on confidentiality, integrity, and availability, assigning risk scores and prioritizing remediation within 30 days.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""(TSC.CC3.6) The Risk Owner shall define and implement risk responses, including acceptance, mitigation, transfer, or avoidance, aligning with Casper Studios’ risk appetite documented in Comp AI.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""(TSC.CC3.7) Quarterly reviews of risk mitigation plans shall be conducted and results reported to senior management to ensure effectiveness.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""(TSC.CC5.2) Potential fraud risks shall be included in the risk matrix and reviewed in collaboration with HR and Legal.\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""4. C. Exceptions Process\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Employees and contractors shall submit exception requests through Linear, including business justification, compensating controls, and duration. The Information Security Officer and Risk Owner shall jointly approve, document, and time-limit exceptions, which shall be reviewed at least quarterly or upon significant risk changes.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""5. D. Violations and Disciplinary Action\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Continuous monitoring using AWS CloudWatch and security logs shall detect non-compliance. Suspected violations must be reported to the Information Security Officer and HR. Confirmed violations shall result in disciplinary actions consistent with HR policies, up to termination, and may include immediate mitigation or legal referral.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""6. Auditor Evidence Artefacts\"", \""type\"": \""text\""}]}"",""{\""type\"": \""bulletList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""AWS CloudTrail and CloudWatch logs\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Risk assessment reports and scoring matrices\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Linear tickets for exception requests and approvals\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Approval records from Slack notifications and email archives\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Screenshots of quarterly review meeting agendas and minutes\"", \""type\"": \""text\""}]}]}]}""}",yearly,,false,{},none,2025-06-11 21:13:54.269,,false,,frk_pt_683d26b7a8705c7002350b01, +pol_6849cce7277a619c250b0459,Secure Development Policy,This policy embeds secure-coding and data-validation practices into the software development life cycle (SDLC) to preserve processing integrity and prevent unauthorized or malformed data from compromising organizational systems.,org_6849cce6df394e5629b1ebd5,published,2025-06-11 18:37:27.218,2025-06-12 00:35:03.831,"{""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Secure Development Policy\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Executive Summary\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Casper Studios shall enforce SOC 2–aligned secure development controls across its AWS-hosted environments and home-office configurations to ensure processing integrity and confidentiality. All secure SDLC processes must be reviewed quarterly by the Application Security Lead and approved by the Information Security Officer. Automated validation, code review, and continuous monitoring shall prevent unauthorized or malformed data from impacting systems.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Table of Contents\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Document Content Page\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Scope and Applicability\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Secure SDLC Controls\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Exception Handling\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Violations and Remediation\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Audit Evidence\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""1. Scope and Applicability\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""This policy applies to all Casper Studios employees, contractors, and third-party service providers who design, develop, test, or maintain software that stores, processes, or transmits employee data in AWS (us-east-1) or home-office networks. Company-provided laptops must enforce disk encryption, endpoint protection, and VPN for cloud resource access. Roles include Software Developers, Application Security Lead, and Information Security Officer.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""2. Secure SDLC Controls\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""2.1 Requirements and Design Review (SOC 2 CC3.1, CC6.1)\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""β€’ Requirements shall be documented in Linear with security criteria and approved by the Application Security Lead before development. \\nβ€’ Architecture diagrams for AWS deployments shall include IAM roles, VPC segmentation, and encryption at rest and in transit (TLS 1.2+).\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""2.2 Implementation and Validation (SOC 2 CC7.1)\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""β€’ All code must pass automated static analysis (SAST) in CI/CD pipelines (GitHub Actions) and dynamic analysis (DAST) monthly. \\nβ€’ Input validation routines must enforce type, length, format, and range checks for all user and API inputs. \\nβ€’ Mandatory fields and schema validation shall be enforced at the application and database layers (RDS PostgreSQL).\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""2.3 Code Review and Approval\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""β€’ Peer code reviews in GitHub are mandatory; pull requests must have approval from at least one senior developer and the Application Security Lead. \\nβ€’ Secrets scanning (GitGuardian) and dependency vulnerability checks (Dependabot) shall block merges until remediated.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""2.4 Deployment and Monitoring\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""β€’ Production deployments via Terraform in AWS require approval tickets in Linear. \\nβ€’ CloudWatch and GuardDuty alerts shall be configured for anomalous activity; the InfoSec Officer reviews alerts daily. \\nβ€’ Quarterly penetration tests shall be conducted by a qualified external vendor.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""3. Exception Handling\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Developers must submit exception requests in Linear, including business justification, compensating controls, and expiration date. The Application Security Lead and Information Security Officer shall jointly evaluate and approve exceptions, which are reviewed at or before expiration.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""4. Violations and Remediation\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Non-compliance detected via automated scans, code reviews, or audit findings must be reported to the Information Security Officer within 24 hours. Violations shall invoke remediation plans with defined timelines, and HR disciplinary action follows confirmed incidents per severity tiers (warning to termination). All security incidents requiring legal notification shall follow the Incident Response Policy.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""5. Audit Evidence\"", \""type\"": \""text\""}]}"",""{\""type\"": \""bulletList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""CI/CD pipeline logs showing SAST/DAST results\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Linear tickets for design reviews and exception approvals\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""GitHub pull-request approvals and secrets-scan screenshots\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""AWS CloudWatch and GuardDuty alert logs\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Quarterly penetration test reports\"", \""type\"": \""text\""}]}]}]}""}",yearly,,false,{},none,2025-06-11 21:14:04.754,,false,,frk_pt_683d2fbdba5115ed83c6652f, +pol_6849cce750ed82756f472a56,Security Awareness & Training Policy,This policy promotes a security-conscious culture by setting behavioral expectations and ensuring all personnel possess the knowledge and qualifications necessary to safeguard organizational assets.,org_6849cce6df394e5629b1ebd5,published,2025-06-11 18:37:27.218,2025-06-12 00:35:27.605,"{""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Table of Contents\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Document Content Page\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""A. Applicability & Scope\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""B. Controls\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""C. Exceptions Process\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""D. Violations & Disciplinary Action\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""E. Auditor Evidence Artefacts\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Executive Summary\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""This Security Awareness & Training Policy establishes mandatory training, acceptable use, and personnel security controls to safeguard Casper Studios’ AWS-hosted systems and data in strict alignment with SOC 2 requirements. It applies to all employees, contractors, and third parties accessing cloud and home-office environments. The policy is reviewed quarterly and audited annually to ensure continued compliance and mitigate risks to employee data and organizational assets.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""A. Applicability & Scope\"", \""type\"": \""text\"", \""marks\"": [{\""type\"": \""bold\""}]}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""This policy applies to all Casper Studios employees, contractors, and third-party vendors accessing or managing information systems, including AWS cloud resources, Google Workspace accounts, company-provided laptops, and home-office networks.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""B. Controls\"", \""type\"": \""text\"", \""marks\"": [{\""type\"": \""bold\""}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""1. Acceptable Use\"", \""type\"": \""text\"", \""marks\"": [{\""type\"": \""bold\""}]}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""(SOC2-TC-1) Personnel must use only approved devices and authenticated Google Workspace or company credentials to access corporate resources; use of personal devices for sensitive operations is prohibited without documented exception.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""(SOC2-TC-2) All access to AWS consoles and related systems must occur over secure VPN or approved secure home-office networks, with multi-factor authentication enabled.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""(SOC2-TC-3) Personnel shall comply with company guidelines for acceptable technology use on Slack, Linear, Figma, and Zoom; violations must be reported to the Information Security Officer.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""(SOC2-CT-4) All staff must acknowledge this policy and complete annual SOC 2 security awareness training by electronic signature; reminders are sent quarterly.\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""2. Personnel Security\"", \""type\"": \""text\"", \""marks\"": [{\""type\"": \""bold\""}]}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""(SOC2-PS-1) Background checks must be completed for all new hires before granting access to corporate or AWS resources.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""(SOC2-PS-2) Information security roles and responsibilities shall be defined in job descriptions; only qualified personnel may fulfill security-related duties.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""(SOC2-PS-3) Security and privacy training tailored to job functions must be delivered during onboarding and refreshed annually.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""(SOC2-PS-4) Training completion records shall be retained in Linear training tickets and reviewed quarterly.\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""C. Exceptions Process\"", \""type\"": \""text\"", \""marks\"": [{\""type\"": \""bold\""}]}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Personnel must submit exception requests via Linear tickets with business justification, compensating controls, and duration. The Information Security Officer and HR Manager shall jointly approve and document each exception; all exceptions are reviewed at or before expiration quarterly.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""D. Violations & Disciplinary Action\"", \""type\"": \""text\"", \""marks\"": [{\""type\"": \""bold\""}]}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Compliance is monitored through quarterly log reviews in AWS CloudTrail and Slack audit logs. Violations must be reported to the Information Security Officer and HR; confirmed breaches shall result in disciplinary action up to termination, based on severity, and may require retraining.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""E. Auditor Evidence Artefacts\"", \""type\"": \""text\"", \""marks\"": [{\""type\"": \""bold\""}]}]}"",""{\""type\"": \""bulletList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Annual training completion logs from Google Workspace and Linear.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""AWS CloudTrail audit logs and VPN connection records.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Signed policy acknowledgments and exception approval tickets.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Records of disciplinary actions and retraining schedules.\"", \""type\"": \""text\""}]}]}]}""}",yearly,,false,{},none,2025-06-11 21:14:16.320,,false,,frk_pt_683d27517ca91b1c3c748256, +pol_6849cce7ac2776486d3e0143,Third-Party Risk Management Policy,"This policy ensures that vendors and other third parties do not introduce unacceptable risk to the organization by establishing a structured program for assessing, monitoring, and mitigating supplier risks aligned with security commitments and regulatory requirements.",org_6849cce6df394e5629b1ebd5,published,2025-06-11 18:37:27.218,2025-06-12 00:36:10.067,"{""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Table of Contents\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Document Content Page\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Applicability & Scope\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Controls\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Exceptions Process\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Violations & Disciplinary Action\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Auditor Evidence Artefacts\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""Executive Summary\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""This Third-Party Risk Management Policy ensures that Casper Studios identifies, evaluates, and mitigates vendor risks in strict alignment with SOC 2 security and confidentiality criteria. It mandates annual risk assessments and quarterly monitoring of all third parties that handle organizational or employee data in AWS and home-office environments. Roles and responsibilities are defined for the Information Security Officer, Vendor Owners, and senior management, with all exceptions documented and approved in Linear within five business days.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""2. Applicability & Scope\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""This policy applies to all Casper Studios employees, contractors, and business units in hybrid office and home-office settings who select, onboard, manage, or rely on third parties that store, process, or transmit organizational or employee data within AWS regions. It applies to all vendor engagements via Google Workspace, Slack, Linear, Figma, Zoom, or other enterprise services. The Information Security Officer shall ensure enforcement across corporate-provided laptops and Google Workspace identities.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""3. Controls\"", \""type\"": \""text\""}]}"",""{\""type\"": \""orderedList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Vendor Classification: All third parties must be classified based on data criticality and SOC 2 risk categories; classification must be reviewed annually.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Due Diligence: The Information Security Officer and Vendor Owner shall conduct formal vendor security assessments at onboarding and recurrently on a 12-month cycle.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Contractual Requirements: All contracts must include SOC 2-required security, confidentiality, and data handling clauses before engagement; Legal shall verify compliance prior to signature.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Continuous Monitoring: Vendor performance metrics and security controls shall be reviewed quarterly, with findings documented via AWS CloudTrail and security dashboards.\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Issue Remediation: All identified vendor control gaps must be remediated within 30 days and tracked in Linear with SLA targets; status is reported by the Vendor Owner.\"", \""type\"": \""text\""}]}]}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""4. Exceptions Process\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Any deviation from this policy must be submitted via Linear with business justification, compensating controls, and time limit. The Information Security Officer and Vendor Owner must jointly approve or reject requests within five business days; all decisions must be documented and reviewed quarterly.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""5. Violations & Disciplinary Action\"", \""type\"": \""text\""}]}"",""{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Non-compliance shall be detected via continuous AWS CloudTrail monitoring and vendor performance reports. Suspected violations must be escalated to the Information Security Officer and HR within three business days. Confirmed violations will result in disciplinary actions up to contract termination, per Casper Studios HR policy.\"", \""type\"": \""text\""}]}"",""{\""type\"": \""heading\"", \""content\"": [{\""text\"": \""6. Auditor Evidence Artefacts\"", \""type\"": \""text\""}]}"",""{\""type\"": \""bulletList\"", \""content\"": [{\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""AWS CloudTrail logs demonstrating vendor access and review dates\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Annual vendor risk assessment reports\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Signed vendor contracts with SOC 2 clauses\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Quarterly monitoring reports and remediation tickets from Linear\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Exception approval records in Linear (timestamps and approvers)\"", \""type\"": \""text\""}]}]}, {\""type\"": \""listItem\"", \""content\"": [{\""type\"": \""paragraph\"", \""content\"": [{\""text\"": \""Screenshots of AWS security dashboard configurations\"", \""type\"": \""text\""}]}]}]}""}",yearly,,false,{},none,2025-06-11 21:14:25.473,,false,,frk_pt_683d2d85d2a665c6334ff5c3, diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000000..b4bfed3579 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,3 @@ +{ + "plugins": ["prettier-plugin-tailwindcss"] +} diff --git a/.syncpackrc.json b/.syncpackrc.json new file mode 100644 index 0000000000..8129b1c030 --- /dev/null +++ b/.syncpackrc.json @@ -0,0 +1,75 @@ +{ + "source": ["package.json", "apps/*/package.json", "packages/*/package.json"], + "dependencyTypes": ["prod", "dev", "peer"], + "semverGroups": [ + { + "label": "Use exact versions for internal packages", + "packages": ["@comp/**"], + "dependencies": ["@comp/**"], + "range": "workspace:*" + } + ], + "versionGroups": [ + { + "label": "Ensure React is consistent", + "packages": ["**"], + "dependencies": [ + "react", + "react-dom", + "@types/react", + "@types/react-dom", + "react-is" + ], + "isIgnored": false + }, + { + "label": "Ensure Next.js is consistent", + "packages": ["**"], + "dependencies": ["next"], + "isIgnored": false + }, + { + "label": "Ensure TypeScript is consistent", + "packages": ["**"], + "dependencies": ["typescript"], + "isIgnored": false + }, + { + "label": "Ensure common build tools are consistent", + "packages": ["**"], + "dependencies": [ + "postcss", + "tailwindcss", + "@tailwindcss/**", + "autoprefixer" + ], + "isIgnored": false + }, + { + "label": "Ensure testing tools are consistent", + "packages": ["**"], + "dependencies": ["@types/node", "prettier", "turbo"], + "isIgnored": false + }, + { + "label": "Ensure ESLint is consistent", + "packages": ["**"], + "dependencies": ["eslint", "eslint-config-next"], + "isIgnored": false + } + ], + "lintRules": { + "forbiddenDependencies": { + "dependencies": [ + "crypto", + "buffer", + "fs", + "path", + "os", + "install", + "npm" + ], + "message": "This is a Node.js built-in module or a mistakenly added dependency" + } + } +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 221cc20be0..6e34cc2864 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -20,5 +20,8 @@ }, "editor.codeActionsOnSave": { "source.fixAll": "explicit" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" } } diff --git a/CHANGELOG.md b/CHANGELOG.md index b72d50b624..7a4186b92d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,604 +1,528 @@ ## [1.41.1](https://github.com/trycompai/comp/compare/v1.41.0...v1.41.1) (2025-06-12) - ### Bug Fixes -* **layout:** center content in RootLayout by adding mx-auto class to the container div ([eece9f6](https://github.com/trycompai/comp/commit/eece9f61d26b34a6e8c51ac88a70e78786847433)) +- **layout:** center content in RootLayout by adding mx-auto class to the container div ([eece9f6](https://github.com/trycompai/comp/commit/eece9f61d26b34a6e8c51ac88a70e78786847433)) # [1.41.0](https://github.com/trycompai/comp/compare/v1.40.0...v1.41.0) (2025-06-11) - ### Features -* **api:** add organization initialization after reset ([89b2d2e](https://github.com/trycompai/comp/commit/89b2d2e97bc9b3f8394e940f5d1ef09552048b90)) +- **api:** add organization initialization after reset ([89b2d2e](https://github.com/trycompai/comp/commit/89b2d2e97bc9b3f8394e940f5d1ef09552048b90)) # [1.40.0](https://github.com/trycompai/comp/compare/v1.39.0...v1.40.0) (2025-06-11) - ### Bug Fixes -* **SidebarCollapseButton:** handle optimistic update rollback on error ([bf6298d](https://github.com/trycompai/comp/commit/bf6298d581c1926f12458ddc815307b874d1712a)) - +- **SidebarCollapseButton:** handle optimistic update rollback on error ([bf6298d](https://github.com/trycompai/comp/commit/bf6298d581c1926f12458ddc815307b874d1712a)) ### Features -* add Fleet integration for endpoint monitoring and management ([748af0e](https://github.com/trycompai/comp/commit/748af0e191f4ad934fd86f3c373c7f48e5854a64)) -* add support for fleet in db ([44f6ee2](https://github.com/trycompai/comp/commit/44f6ee2ad8e82337ed5b9a8734a2d720d7cef2fe)) -* **components:** introduce CardLiquidGlass component and update PageCore layout ([4345ff1](https://github.com/trycompai/comp/commit/4345ff16c6151e575ea7ddfa03447d1a0ed4896d)) -* **design-system:** add new design system rules ([966d8d8](https://github.com/trycompai/comp/commit/966d8d8cd1515219d97ff62d673ba7954182c3d6)) -* employee devices showing in app and portal ([5a71b5b](https://github.com/trycompai/comp/commit/5a71b5bed2f2f19a2f8dbbc05fbecb52a1fa24bc)) -* **header:** add inbox icon to feedback link ([ed32c23](https://github.com/trycompai/comp/commit/ed32c23a80d5a388f58ce5003706ebab5c0bcd57)) -* list out employee devices and overview chart ([7c2f8bb](https://github.com/trycompai/comp/commit/7c2f8bb7f1a616d3aa0af32df9ac8ee1b647700e)) -* **policies:** introduce new layout component for policies overview ([87d25a0](https://github.com/trycompai/comp/commit/87d25a05376c628555d75bd52002d44ddc1d921d)) -* **ui:** add search icon to input components ([44ea88c](https://github.com/trycompai/comp/commit/44ea88c41c1c06216788680f68fd6a30dd4ab7cf)) +- add Fleet integration for endpoint monitoring and management ([748af0e](https://github.com/trycompai/comp/commit/748af0e191f4ad934fd86f3c373c7f48e5854a64)) +- add support for fleet in db ([44f6ee2](https://github.com/trycompai/comp/commit/44f6ee2ad8e82337ed5b9a8734a2d720d7cef2fe)) +- **components:** introduce CardLiquidGlass component and update PageCore layout ([4345ff1](https://github.com/trycompai/comp/commit/4345ff16c6151e575ea7ddfa03447d1a0ed4896d)) +- **design-system:** add new design system rules ([966d8d8](https://github.com/trycompai/comp/commit/966d8d8cd1515219d97ff62d673ba7954182c3d6)) +- employee devices showing in app and portal ([5a71b5b](https://github.com/trycompai/comp/commit/5a71b5bed2f2f19a2f8dbbc05fbecb52a1fa24bc)) +- **header:** add inbox icon to feedback link ([ed32c23](https://github.com/trycompai/comp/commit/ed32c23a80d5a388f58ce5003706ebab5c0bcd57)) +- list out employee devices and overview chart ([7c2f8bb](https://github.com/trycompai/comp/commit/7c2f8bb7f1a616d3aa0af32df9ac8ee1b647700e)) +- **policies:** introduce new layout component for policies overview ([87d25a0](https://github.com/trycompai/comp/commit/87d25a05376c628555d75bd52002d44ddc1d921d)) +- **ui:** add search icon to input components ([44ea88c](https://github.com/trycompai/comp/commit/44ea88c41c1c06216788680f68fd6a30dd4ab7cf)) # [1.39.0](https://github.com/trycompai/comp/compare/v1.38.0...v1.39.0) (2025-06-09) - ### Features -* **api:** reset onboarding status during org reset ([5b7e531](https://github.com/trycompai/comp/commit/5b7e531e6115eb4c9ef02e70e9e2044c28ad7a41)) +- **api:** reset onboarding status during org reset ([5b7e531](https://github.com/trycompai/comp/commit/5b7e531e6115eb4c9ef02e70e9e2044c28ad7a41)) # [1.38.0](https://github.com/trycompai/comp/compare/v1.37.0...v1.38.0) (2025-06-09) - ### Features -* **api:** add reset organization endpoint ([9a118d1](https://github.com/trycompai/comp/commit/9a118d1d1985c6e4b1055af97412f597eb365dfe)) +- **api:** add reset organization endpoint ([9a118d1](https://github.com/trycompai/comp/commit/9a118d1d1985c6e4b1055af97412f597eb365dfe)) # [1.37.0](https://github.com/trycompai/comp/compare/v1.36.3...v1.37.0) (2025-06-09) - ### Features -* **status:** add 'not_relevant' status and update related components ([e1b6597](https://github.com/trycompai/comp/commit/e1b6597d9f2e3566ccd61bbe0391397f7960b2e5)) -* **TaskStatusIndicator:** add 'not_relevant' status indicator ([ca2d9e0](https://github.com/trycompai/comp/commit/ca2d9e0cb2c2d80ab2b089ae5e7ce6fae3d9c187)) +- **status:** add 'not_relevant' status and update related components ([e1b6597](https://github.com/trycompai/comp/commit/e1b6597d9f2e3566ccd61bbe0391397f7960b2e5)) +- **TaskStatusIndicator:** add 'not_relevant' status indicator ([ca2d9e0](https://github.com/trycompai/comp/commit/ca2d9e0cb2c2d80ab2b089ae5e7ce6fae3d9c187)) ## [1.36.3](https://github.com/trycompai/comp/compare/v1.36.2...v1.36.3) (2025-06-08) - ### Bug Fixes -* fix length of onboarding popover options ([8441015](https://github.com/trycompai/comp/commit/8441015439f2a1c8a442661927ff41321b2b8e77)) -* fix updating title and description of a policy ([44c2fe8](https://github.com/trycompai/comp/commit/44c2fe8075f602617868ef749335efbdcf37e12f)) -* make whole row clickable for select-pills ([7bb85f4](https://github.com/trycompai/comp/commit/7bb85f4d213184697bfbf0f5f592fd450ff0ab04)) -* **tasks:** ensure grid row selection stable ([b10e8b8](https://github.com/trycompai/comp/commit/b10e8b8ad773bfd183f7b005fe3473618d16e147)) +- fix length of onboarding popover options ([8441015](https://github.com/trycompai/comp/commit/8441015439f2a1c8a442661927ff41321b2b8e77)) +- fix updating title and description of a policy ([44c2fe8](https://github.com/trycompai/comp/commit/44c2fe8075f602617868ef749335efbdcf37e12f)) +- make whole row clickable for select-pills ([7bb85f4](https://github.com/trycompai/comp/commit/7bb85f4d213184697bfbf0f5f592fd450ff0ab04)) +- **tasks:** ensure grid row selection stable ([b10e8b8](https://github.com/trycompai/comp/commit/b10e8b8ad773bfd183f7b005fe3473618d16e147)) ## [1.36.2](https://github.com/trycompai/comp/compare/v1.36.1...v1.36.2) (2025-06-07) - ### Bug Fixes -* fix layout issue with risk matrix chart layout ([55e2985](https://github.com/trycompai/comp/commit/55e298503fd1eb3e2d07c792b7292052985a4c8e)) +- fix layout issue with risk matrix chart layout ([55e2985](https://github.com/trycompai/comp/commit/55e298503fd1eb3e2d07c792b7292052985a4c8e)) ## [1.36.1](https://github.com/trycompai/comp/compare/v1.36.0...v1.36.1) (2025-06-07) - ### Bug Fixes -* fix both edit role and cancel invitation issues with dialog ([37f783b](https://github.com/trycompai/comp/commit/37f783baa04ee4395d19ebaee026988e5833bc24)) +- fix both edit role and cancel invitation issues with dialog ([37f783b](https://github.com/trycompai/comp/commit/37f783baa04ee4395d19ebaee026988e5833bc24)) # [1.36.0](https://github.com/trycompai/comp/compare/v1.35.1...v1.36.0) (2025-06-06) - ### Features -* add materialized view for organization statistics ([f44f375](https://github.com/trycompai/comp/commit/f44f375b38853137ed1bdbe0bd07a5e9f3873d10)) -* add scheduled task to refresh OrganizationStats materialized view ([b04baf4](https://github.com/trycompai/comp/commit/b04baf497bc79ea1520f6ada8a11a1f8132d81fe)) +- add materialized view for organization statistics ([f44f375](https://github.com/trycompai/comp/commit/f44f375b38853137ed1bdbe0bd07a5e9f3873d10)) +- add scheduled task to refresh OrganizationStats materialized view ([b04baf4](https://github.com/trycompai/comp/commit/b04baf497bc79ea1520f6ada8a11a1f8132d81fe)) ## [1.35.1](https://github.com/trycompai/comp/compare/v1.35.0...v1.35.1) (2025-06-06) - ### Bug Fixes -* fix issue with redirect to onboarding ([f9d3e4d](https://github.com/trycompai/comp/commit/f9d3e4de5576812b45c1da5df8f03b12f086ad6f)) +- fix issue with redirect to onboarding ([f9d3e4d](https://github.com/trycompai/comp/commit/f9d3e4de5576812b45c1da5df8f03b12f086ad6f)) # [1.35.0](https://github.com/trycompai/comp/compare/v1.34.0...v1.35.0) (2025-06-06) - ### Features -* add readonly role with SELECT permissions for all non-system schemas ([b7ba0f9](https://github.com/trycompai/comp/commit/b7ba0f940d7edaaf9e12d66f11f160b29fd067f6)) +- add readonly role with SELECT permissions for all non-system schemas ([b7ba0f9](https://github.com/trycompai/comp/commit/b7ba0f940d7edaaf9e12d66f11f160b29fd067f6)) # [1.34.0](https://github.com/trycompai/comp/compare/v1.33.1...v1.34.0) (2025-06-05) - ### Bug Fixes -* update organization ID reference in OrganizationSwitcher component ([a930de6](https://github.com/trycompai/comp/commit/a930de64f48e12e03ed361f51597b16b3a979fe3)) - +- update organization ID reference in OrganizationSwitcher component ([a930de6](https://github.com/trycompai/comp/commit/a930de64f48e12e03ed361f51597b16b3a979fe3)) ### Features -* add cascading delete constraints for organization relationships ([1b7fbb6](https://github.com/trycompai/comp/commit/1b7fbb6694e0104eebaa35226be0cdcd5ed3213a)) -* add script to remove localization from the codebase ([54e36c4](https://github.com/trycompai/comp/commit/54e36c43d2ae3e6c1808a644ad62bf29c953aaab)) -* implement localization removal script and TypeScript configuration ([90d944b](https://github.com/trycompai/comp/commit/90d944bfb85d8a7d8b53ab8789c6eeff290e6431)) +- add cascading delete constraints for organization relationships ([1b7fbb6](https://github.com/trycompai/comp/commit/1b7fbb6694e0104eebaa35226be0cdcd5ed3213a)) +- add script to remove localization from the codebase ([54e36c4](https://github.com/trycompai/comp/commit/54e36c43d2ae3e6c1808a644ad62bf29c953aaab)) +- implement localization removal script and TypeScript configuration ([90d944b](https://github.com/trycompai/comp/commit/90d944bfb85d8a7d8b53ab8789c6eeff290e6431)) ## [1.33.1](https://github.com/trycompai/comp/compare/v1.33.0...v1.33.1) (2025-06-05) - ### Bug Fixes -* enable organization search ([88a8b71](https://github.com/trycompai/comp/commit/88a8b71dd2d84bf49175f6ecbcc3454d7271f2e4)) +- enable organization search ([88a8b71](https://github.com/trycompai/comp/commit/88a8b71dd2d84bf49175f6ecbcc3454d7271f2e4)) # [1.33.0](https://github.com/trycompai/comp/compare/v1.32.3...v1.33.0) (2025-06-05) - ### Bug Fixes -* enable nodeMiddleware in Next.js configuration for app and trust ([f4ab998](https://github.com/trycompai/comp/commit/f4ab9982c570bc17bf0361d6d52753e8c15bc01e)) -* enhance hydration handling in OnboardingForm component ([94aabef](https://github.com/trycompai/comp/commit/94aabef6e939689f62613ac7f1eae8c07bdface5)) -* **middleware:** remove unnecessary URL encoding for redirect in authentication check ([33edd96](https://github.com/trycompai/comp/commit/33edd965774b7574d831477a13e3b15dbf8dffc6)) -* **migration:** correct foreign key constraint addition in Context table ([552dc2c](https://github.com/trycompai/comp/commit/552dc2cd6c13fc461b1b8b64e96b79dcc4e48c33)) -* **onboarding:** add conditional check for policies before batch processing ([a7d53ad](https://github.com/trycompai/comp/commit/a7d53ade77260989a4241aa2e9ea166162216bfd)) -* **onboarding:** simplify onboarding path checks in middleware ([864de65](https://github.com/trycompai/comp/commit/864de65a2c1951caae1acbd06ce047392ab02985)) -* **onboarding:** update onboarding completion logic ([1a9c875](https://github.com/trycompai/comp/commit/1a9c87580ae591e7df37ee2cbcd386fcad9d7588)) -* remove minWidth style from OnboardingForm component ([a784166](https://github.com/trycompai/comp/commit/a784166016354f33e9cdcf0f7047806c88ac8acf)) -* update placeholder text in OnboardingForm for clarity ([760f7b5](https://github.com/trycompai/comp/commit/760f7b53b07d6434b645870e09946657d8ee936c)) -* **vendor:** map vendor categories to user-friendly labels in VendorColumns component ([8b6600a](https://github.com/trycompai/comp/commit/8b6600a60177f95639787c413fdba62c4a345f60)) - - -### Features - -* add company description field to onboarding form ([92a0535](https://github.com/trycompai/comp/commit/92a05356358b095811fbc709230c7af49c4144d2)) -* add conditional migration for onboarding data transfer ([06e6ef5](https://github.com/trycompai/comp/commit/06e6ef593423b4f0e691a6c7266fecdfb0fa990f)) -* add context hub settings and onboarding components ([cf97f8d](https://github.com/trycompai/comp/commit/cf97f8d0d1bdd7e398397831fbac2cc9e1dcd7fa)) -* add isCollapsed prop to SidebarLogo component ([90d1ceb](https://github.com/trycompai/comp/commit/90d1ceb07cff15b17ef45df869e2b5109eb65fcd)) -* add migration to transfer onboarding data to context table ([7a441ad](https://github.com/trycompai/comp/commit/7a441adb4c3342366b24537bfaaa00c2899dbf02)) -* add software selection to onboarding form ([9298666](https://github.com/trycompai/comp/commit/9298666b97225fcfb0f98d98df0d15f9d5bcf28f)) -* check for existing vendor before research task execution ([81c174e](https://github.com/trycompai/comp/commit/81c174e847b69043d91781412dc38471d82fd207)) -* enhance context entry forms with placeholders and descriptions ([6bc4ac1](https://github.com/trycompai/comp/commit/6bc4ac1ee162f1e9a0505ce94a7d44bd4a406d22)) -* enhance framework templates and relationships ([05f671b](https://github.com/trycompai/comp/commit/05f671bbf3abb68056320114d7829bb7d8aeec6a)) -* enhance onboarding form with additional software options ([e9727d9](https://github.com/trycompai/comp/commit/e9727d992331c8150075e33592f637904dd150ae)) -* enhance settings layout and API key management ([570837b](https://github.com/trycompai/comp/commit/570837b5044c16ba42075d6c3065aa9d929a2fe6)) -* implement policy update functionality with AI-generated prompts ([c70db73](https://github.com/trycompai/comp/commit/c70db73e3003d3615ebef5b09865dca3fc69a9fe)) -* move onboarding loading to layout ([bd8f0e3](https://github.com/trycompai/comp/commit/bd8f0e3f0a9115fb80d6e71714c479bcba798f5d)) -* **new:** trigger.dev job to onboard new users, added trigger.dev loading screen for onboarding ([1684fa6](https://github.com/trycompai/comp/commit/1684fa6a249ed53983a4dce8529c083ecc5f479c)) -* **tasks:** enhance task management with control linking functionality ([44678bf](https://github.com/trycompai/comp/commit/44678bf2ac004bb2bd1394df4420e1674ecaccdc)) -* **tasks:** implement task management actions and UI enhancements ([be12434](https://github.com/trycompai/comp/commit/be124345da0595440ae57fc7258807d62dfe9911)) -* update dependencies and enhance task management features ([61b48e9](https://github.com/trycompai/comp/commit/61b48e95482310085f3251704ff54ee5b8727d84)) -* update onboarding process to set default completion status ([a231c23](https://github.com/trycompai/comp/commit/a231c23f92ee1003b1171d1889a799fe3fe6a2ae)) +- enable nodeMiddleware in Next.js configuration for app and trust ([f4ab998](https://github.com/trycompai/comp/commit/f4ab9982c570bc17bf0361d6d52753e8c15bc01e)) +- enhance hydration handling in OnboardingForm component ([94aabef](https://github.com/trycompai/comp/commit/94aabef6e939689f62613ac7f1eae8c07bdface5)) +- **middleware:** remove unnecessary URL encoding for redirect in authentication check ([33edd96](https://github.com/trycompai/comp/commit/33edd965774b7574d831477a13e3b15dbf8dffc6)) +- **migration:** correct foreign key constraint addition in Context table ([552dc2c](https://github.com/trycompai/comp/commit/552dc2cd6c13fc461b1b8b64e96b79dcc4e48c33)) +- **onboarding:** add conditional check for policies before batch processing ([a7d53ad](https://github.com/trycompai/comp/commit/a7d53ade77260989a4241aa2e9ea166162216bfd)) +- **onboarding:** simplify onboarding path checks in middleware ([864de65](https://github.com/trycompai/comp/commit/864de65a2c1951caae1acbd06ce047392ab02985)) +- **onboarding:** update onboarding completion logic ([1a9c875](https://github.com/trycompai/comp/commit/1a9c87580ae591e7df37ee2cbcd386fcad9d7588)) +- remove minWidth style from OnboardingForm component ([a784166](https://github.com/trycompai/comp/commit/a784166016354f33e9cdcf0f7047806c88ac8acf)) +- update placeholder text in OnboardingForm for clarity ([760f7b5](https://github.com/trycompai/comp/commit/760f7b53b07d6434b645870e09946657d8ee936c)) +- **vendor:** map vendor categories to user-friendly labels in VendorColumns component ([8b6600a](https://github.com/trycompai/comp/commit/8b6600a60177f95639787c413fdba62c4a345f60)) + +### Features + +- add company description field to onboarding form ([92a0535](https://github.com/trycompai/comp/commit/92a05356358b095811fbc709230c7af49c4144d2)) +- add conditional migration for onboarding data transfer ([06e6ef5](https://github.com/trycompai/comp/commit/06e6ef593423b4f0e691a6c7266fecdfb0fa990f)) +- add context hub settings and onboarding components ([cf97f8d](https://github.com/trycompai/comp/commit/cf97f8d0d1bdd7e398397831fbac2cc9e1dcd7fa)) +- add isCollapsed prop to SidebarLogo component ([90d1ceb](https://github.com/trycompai/comp/commit/90d1ceb07cff15b17ef45df869e2b5109eb65fcd)) +- add migration to transfer onboarding data to context table ([7a441ad](https://github.com/trycompai/comp/commit/7a441adb4c3342366b24537bfaaa00c2899dbf02)) +- add software selection to onboarding form ([9298666](https://github.com/trycompai/comp/commit/9298666b97225fcfb0f98d98df0d15f9d5bcf28f)) +- check for existing vendor before research task execution ([81c174e](https://github.com/trycompai/comp/commit/81c174e847b69043d91781412dc38471d82fd207)) +- enhance context entry forms with placeholders and descriptions ([6bc4ac1](https://github.com/trycompai/comp/commit/6bc4ac1ee162f1e9a0505ce94a7d44bd4a406d22)) +- enhance framework templates and relationships ([05f671b](https://github.com/trycompai/comp/commit/05f671bbf3abb68056320114d7829bb7d8aeec6a)) +- enhance onboarding form with additional software options ([e9727d9](https://github.com/trycompai/comp/commit/e9727d992331c8150075e33592f637904dd150ae)) +- enhance settings layout and API key management ([570837b](https://github.com/trycompai/comp/commit/570837b5044c16ba42075d6c3065aa9d929a2fe6)) +- implement policy update functionality with AI-generated prompts ([c70db73](https://github.com/trycompai/comp/commit/c70db73e3003d3615ebef5b09865dca3fc69a9fe)) +- move onboarding loading to layout ([bd8f0e3](https://github.com/trycompai/comp/commit/bd8f0e3f0a9115fb80d6e71714c479bcba798f5d)) +- **new:** trigger.dev job to onboard new users, added trigger.dev loading screen for onboarding ([1684fa6](https://github.com/trycompai/comp/commit/1684fa6a249ed53983a4dce8529c083ecc5f479c)) +- **tasks:** enhance task management with control linking functionality ([44678bf](https://github.com/trycompai/comp/commit/44678bf2ac004bb2bd1394df4420e1674ecaccdc)) +- **tasks:** implement task management actions and UI enhancements ([be12434](https://github.com/trycompai/comp/commit/be124345da0595440ae57fc7258807d62dfe9911)) +- update dependencies and enhance task management features ([61b48e9](https://github.com/trycompai/comp/commit/61b48e95482310085f3251704ff54ee5b8727d84)) +- update onboarding process to set default completion status ([a231c23](https://github.com/trycompai/comp/commit/a231c23f92ee1003b1171d1889a799fe3fe6a2ae)) ## [1.32.3](https://github.com/trycompai/comp/compare/v1.32.2...v1.32.3) (2025-05-30) - ### Bug Fixes -* fix issue with scrollbar showing up when not needed ([8a2a3e7](https://github.com/trycompai/comp/commit/8a2a3e7e621ae36ce3e5e90729ef9540794d68b4)) +- fix issue with scrollbar showing up when not needed ([8a2a3e7](https://github.com/trycompai/comp/commit/8a2a3e7e621ae36ce3e5e90729ef9540794d68b4)) ## [1.32.2](https://github.com/trycompai/comp/compare/v1.32.1...v1.32.2) (2025-05-30) - ### Bug Fixes -* issue with selecting role for inviting members on firefox ([2bd3895](https://github.com/trycompai/comp/commit/2bd3895f945ccf66672fa63c8494b80fe81b9ee9)) +- issue with selecting role for inviting members on firefox ([2bd3895](https://github.com/trycompai/comp/commit/2bd3895f945ccf66672fa63c8494b80fe81b9ee9)) ## [1.32.1](https://github.com/trycompai/comp/compare/v1.32.0...v1.32.1) (2025-05-30) - ### Bug Fixes -* **invite:** allow role selection in modal ([2898ffd](https://github.com/trycompai/comp/commit/2898ffde8e8d0f00bf481f20028c3a3c1279f30a)) +- **invite:** allow role selection in modal ([2898ffd](https://github.com/trycompai/comp/commit/2898ffde8e8d0f00bf481f20028c3a3c1279f30a)) # [1.32.0](https://github.com/trycompai/comp/compare/v1.31.0...v1.32.0) (2025-05-26) - ### Features -* implement client-side filtering in controls and frameworks tables for enhanced search functionality ([010dfe0](https://github.com/trycompai/comp/commit/010dfe022fb7693c3d87ea5cd7c8682b1f7b7ab3)) +- implement client-side filtering in controls and frameworks tables for enhanced search functionality ([010dfe0](https://github.com/trycompai/comp/commit/010dfe022fb7693c3d87ea5cd7c8682b1f7b7ab3)) # [1.31.0](https://github.com/trycompai/comp/compare/v1.30.3...v1.31.0) (2025-05-26) - ### Features -* add ability to delete tasks ([948c78d](https://github.com/trycompai/comp/commit/948c78d9e67bdb66803bbcf621ef25543ebd09e9)) +- add ability to delete tasks ([948c78d](https://github.com/trycompai/comp/commit/948c78d9e67bdb66803bbcf621ef25543ebd09e9)) ## [1.30.3](https://github.com/trycompai/comp/compare/v1.30.2...v1.30.3) (2025-05-25) - ### Bug Fixes -* **policy:** align creation schema ([256f111](https://github.com/trycompai/comp/commit/256f1117e003ccb45b7bb7575685b0fce0b7c8f4)) +- **policy:** align creation schema ([256f111](https://github.com/trycompai/comp/commit/256f1117e003ccb45b7bb7575685b0fce0b7c8f4)) ## [1.30.2](https://github.com/trycompai/comp/compare/v1.30.1...v1.30.2) (2025-05-23) - ### Bug Fixes -* add ability to delete controls ([ca6c95c](https://github.com/trycompai/comp/commit/ca6c95ce9debdbd9a26651393360f1235dd3ce55)) -* added ability to delete a policy ([aaf6f53](https://github.com/trycompai/comp/commit/aaf6f53dd5156fd5223561fcf7c582612a139921)) -* allow deleting entire framework ([bc32f8d](https://github.com/trycompai/comp/commit/bc32f8d84dc7d59d03e7a4f9792d8daaaf2874e1)) +- add ability to delete controls ([ca6c95c](https://github.com/trycompai/comp/commit/ca6c95ce9debdbd9a26651393360f1235dd3ce55)) +- added ability to delete a policy ([aaf6f53](https://github.com/trycompai/comp/commit/aaf6f53dd5156fd5223561fcf7c582612a139921)) +- allow deleting entire framework ([bc32f8d](https://github.com/trycompai/comp/commit/bc32f8d84dc7d59d03e7a4f9792d8daaaf2874e1)) ## [1.30.1](https://github.com/trycompai/comp/compare/v1.30.0...v1.30.1) (2025-05-22) - ### Bug Fixes -* **policy:** improve pending changes alert dark mode ([7f9ac23](https://github.com/trycompai/comp/commit/7f9ac238b315c24fc72db19dc75007185a9581f3)) +- **policy:** improve pending changes alert dark mode ([7f9ac23](https://github.com/trycompai/comp/commit/7f9ac238b315c24fc72db19dc75007185a9581f3)) # [1.30.0](https://github.com/trycompai/comp/compare/v1.29.0...v1.30.0) (2025-05-22) - ### Features -* add approval / denial of policy changes with audit logs and comments ([c512e1f](https://github.com/trycompai/comp/commit/c512e1f82b0e261702158672d6907f47ccaed341)) +- add approval / denial of policy changes with audit logs and comments ([c512e1f](https://github.com/trycompai/comp/commit/c512e1f82b0e261702158672d6907f47ccaed341)) # [1.29.0](https://github.com/trycompai/comp/compare/v1.28.0...v1.29.0) (2025-05-22) - ### Features -* add framework editor schemas and seeding functionality ([c2343fd](https://github.com/trycompai/comp/commit/c2343fda9e63a1c015c500355635428dbbb8cadc)) +- add framework editor schemas and seeding functionality ([c2343fd](https://github.com/trycompai/comp/commit/c2343fda9e63a1c015c500355635428dbbb8cadc)) # [1.28.0](https://github.com/trycompai/comp/compare/v1.27.0...v1.28.0) (2025-05-19) - ### Features -* implement framework addition functionality in the dashboard ([073a2d4](https://github.com/trycompai/comp/commit/073a2d4146d8cc398b8a99d625809af346beeaf4)) +- implement framework addition functionality in the dashboard ([073a2d4](https://github.com/trycompai/comp/commit/073a2d4146d8cc398b8a99d625809af346beeaf4)) # [1.27.0](https://github.com/trycompai/comp/compare/v1.26.0...v1.27.0) (2025-05-19) - ### Bug Fixes -* add comment to seed script for clarity ([03d3bc0](https://github.com/trycompai/comp/commit/03d3bc02258f0d6adc06e8b97dabbcbb85eb3852)) -* ensure foreign key constraints are correctly defined for framework and requirement mappings ([e3c3cc2](https://github.com/trycompai/comp/commit/e3c3cc2ba59801abd97cb41bbfb9fe5f0aef7051)) - +- add comment to seed script for clarity ([03d3bc0](https://github.com/trycompai/comp/commit/03d3bc02258f0d6adc06e8b97dabbcbb85eb3852)) +- ensure foreign key constraints are correctly defined for framework and requirement mappings ([e3c3cc2](https://github.com/trycompai/comp/commit/e3c3cc2ba59801abd97cb41bbfb9fe5f0aef7051)) ### Features -* add 'visible' property to frameworks across components ([912e87d](https://github.com/trycompai/comp/commit/912e87d0727cf37d8e3af3b41631e91cc9002901)) -* add getRequirementDetails utility function for requirement retrieval ([69fac45](https://github.com/trycompai/comp/commit/69fac45bc13f184b3b0fc8707163eaef4518c484)) -* add migrations to update framework and requirement relationships ([9b97975](https://github.com/trycompai/comp/commit/9b9797507aef6998aa0e42686b321e374e4d1c3e)) -* add template references to database models and update organization initialization ([09a90cd](https://github.com/trycompai/comp/commit/09a90cde3977b1070d5d02bd0c7b6a99fdd680a8)) -* add visibility toggle to FrameworkEditorFramework model ([1a0176f](https://github.com/trycompai/comp/commit/1a0176fb5676312132f9bd13eec4e022e3128fdc)) -* drop Artifact and _ArtifactToControl tables, migrate relationships to new _ControlToPolicy table ([419fc3f](https://github.com/trycompai/comp/commit/419fc3f63fdd28d320d5b7872f3f9855951bfe1a)) -* enhance ControlsClientPage with framework filtering and control creation ([9949666](https://github.com/trycompai/comp/commit/99496667b5433228731821b21220a204b5b2b4f7)) -* enhance CreateOrgModal layout and add database seeding functionality ([0a79414](https://github.com/trycompai/comp/commit/0a794142123c3282542051973a33af4959b42252)) -* enhance FrameworkEditorFramework with visibility feature ([e8b7a8a](https://github.com/trycompai/comp/commit/e8b7a8a680ad57835e250dcb030e67ce841e88c9)) -* enhance getControl function to include nested framework details ([8c8561c](https://github.com/trycompai/comp/commit/8c8561c62eca8df052704231e8410a698b315a61)) -* **framework:** refactor framework handling and enhance organization creation ([5f53dc2](https://github.com/trycompai/comp/commit/5f53dc2327ef78626462ba02ae84963d25b3b433)) -* refactor framework requirements handling and integrate database fetching ([89b1c2c](https://github.com/trycompai/comp/commit/89b1c2c06d053216e3c053fe67c9a9572feb89cf)) -* **schema:** add TODOs for framework and requirement relations ([3982373](https://github.com/trycompai/comp/commit/398237366e23541530b140be5ba189b9531177b9)) -* **schema:** update organization schema and control types for improved validation and type safety ([f19fe92](https://github.com/trycompai/comp/commit/f19fe92c3c368a317283b84ce0b39751880227af)) -* update RequirementsTable to utilize nested requirement structure ([b0f2525](https://github.com/trycompai/comp/commit/b0f252566cdc1ea099d7b52f5bedce683f545c5f)) -* update SingleControl component to support nested framework structure ([e544f4e](https://github.com/trycompai/comp/commit/e544f4e110a2706c4c497b501beeebd179939e13)) +- add 'visible' property to frameworks across components ([912e87d](https://github.com/trycompai/comp/commit/912e87d0727cf37d8e3af3b41631e91cc9002901)) +- add getRequirementDetails utility function for requirement retrieval ([69fac45](https://github.com/trycompai/comp/commit/69fac45bc13f184b3b0fc8707163eaef4518c484)) +- add migrations to update framework and requirement relationships ([9b97975](https://github.com/trycompai/comp/commit/9b9797507aef6998aa0e42686b321e374e4d1c3e)) +- add template references to database models and update organization initialization ([09a90cd](https://github.com/trycompai/comp/commit/09a90cde3977b1070d5d02bd0c7b6a99fdd680a8)) +- add visibility toggle to FrameworkEditorFramework model ([1a0176f](https://github.com/trycompai/comp/commit/1a0176fb5676312132f9bd13eec4e022e3128fdc)) +- drop Artifact and \_ArtifactToControl tables, migrate relationships to new \_ControlToPolicy table ([419fc3f](https://github.com/trycompai/comp/commit/419fc3f63fdd28d320d5b7872f3f9855951bfe1a)) +- enhance ControlsClientPage with framework filtering and control creation ([9949666](https://github.com/trycompai/comp/commit/99496667b5433228731821b21220a204b5b2b4f7)) +- enhance CreateOrgModal layout and add database seeding functionality ([0a79414](https://github.com/trycompai/comp/commit/0a794142123c3282542051973a33af4959b42252)) +- enhance FrameworkEditorFramework with visibility feature ([e8b7a8a](https://github.com/trycompai/comp/commit/e8b7a8a680ad57835e250dcb030e67ce841e88c9)) +- enhance getControl function to include nested framework details ([8c8561c](https://github.com/trycompai/comp/commit/8c8561c62eca8df052704231e8410a698b315a61)) +- **framework:** refactor framework handling and enhance organization creation ([5f53dc2](https://github.com/trycompai/comp/commit/5f53dc2327ef78626462ba02ae84963d25b3b433)) +- refactor framework requirements handling and integrate database fetching ([89b1c2c](https://github.com/trycompai/comp/commit/89b1c2c06d053216e3c053fe67c9a9572feb89cf)) +- **schema:** add TODOs for framework and requirement relations ([3982373](https://github.com/trycompai/comp/commit/398237366e23541530b140be5ba189b9531177b9)) +- **schema:** update organization schema and control types for improved validation and type safety ([f19fe92](https://github.com/trycompai/comp/commit/f19fe92c3c368a317283b84ce0b39751880227af)) +- update RequirementsTable to utilize nested requirement structure ([b0f2525](https://github.com/trycompai/comp/commit/b0f252566cdc1ea099d7b52f5bedce683f545c5f)) +- update SingleControl component to support nested framework structure ([e544f4e](https://github.com/trycompai/comp/commit/e544f4e110a2706c4c497b501beeebd179939e13)) # [1.26.0](https://github.com/trycompai/comp/compare/v1.25.0...v1.26.0) (2025-05-19) - ### Bug Fixes -* **analytics:** keep client active ([42ec348](https://github.com/trycompai/comp/commit/42ec348ad52d76c521d9f91607958d311b2f5bf7)) -* **layout:** make sidebar scrollable ([82eb566](https://github.com/trycompai/comp/commit/82eb56613c6c7ba28011d816018b997d4080a619)) -* use custom IDs wording ([54dcc86](https://github.com/trycompai/comp/commit/54dcc86ac2e49a6fe104b39cec73893dbb86c21c)) - +- **analytics:** keep client active ([42ec348](https://github.com/trycompai/comp/commit/42ec348ad52d76c521d9f91607958d311b2f5bf7)) +- **layout:** make sidebar scrollable ([82eb566](https://github.com/trycompai/comp/commit/82eb56613c6c7ba28011d816018b997d4080a619)) +- use custom IDs wording ([54dcc86](https://github.com/trycompai/comp/commit/54dcc86ac2e49a6fe104b39cec73893dbb86c21c)) ### Features -* integrate Calcom components and enhance onboarding checklist ([42fe663](https://github.com/trycompai/comp/commit/42fe66323a114dff3bbcf1c8bd026a83d1cd66f9)) +- integrate Calcom components and enhance onboarding checklist ([42fe663](https://github.com/trycompai/comp/commit/42fe66323a114dff3bbcf1c8bd026a83d1cd66f9)) # [1.25.0](https://github.com/trycompai/comp/compare/v1.24.0...v1.25.0) (2025-05-17) - ### Features -* **migrations:** add foreign key constraints and update frameworkId for SOC2 requirements ([f42ac96](https://github.com/trycompai/comp/commit/f42ac96d12a8e364804e94f7fa2542411acdd17a)) +- **migrations:** add foreign key constraints and update frameworkId for SOC2 requirements ([f42ac96](https://github.com/trycompai/comp/commit/f42ac96d12a8e364804e94f7fa2542411acdd17a)) # [1.24.0](https://github.com/trycompai/comp/compare/v1.23.0...v1.24.0) (2025-05-16) - ### Features -* add auth wall on frameworks tool ([1e0667d](https://github.com/trycompai/comp/commit/1e0667d09f645465f445c18db33bf36619074380)) +- add auth wall on frameworks tool ([1e0667d](https://github.com/trycompai/comp/commit/1e0667d09f645465f445c18db33bf36619074380)) # [1.23.0](https://github.com/trycompai/comp/compare/v1.22.0...v1.23.0) (2025-05-16) - ### Features -* **frameworks:** include controls in task retrieval across various components ([bfbad29](https://github.com/trycompai/comp/commit/bfbad293ade92a8e165c390759f7ab0eab4dfae0)) +- **frameworks:** include controls in task retrieval across various components ([bfbad29](https://github.com/trycompai/comp/commit/bfbad293ade92a8e165c390759f7ab0eab4dfae0)) # [1.22.0](https://github.com/trycompai/comp/compare/v1.21.0...v1.22.0) (2025-05-16) - ### Bug Fixes -* **control-progress:** streamline task retrieval in getOrganizationControlProgress function ([c71aa91](https://github.com/trycompai/comp/commit/c71aa915796ca4249e52942fe723cd7f754aa14c)) - +- **control-progress:** streamline task retrieval in getOrganizationControlProgress function ([c71aa91](https://github.com/trycompai/comp/commit/c71aa915796ca4249e52942fe723cd7f754aa14c)) ### Features -* **migration:** add many-to-many support for tasks ([cebda99](https://github.com/trycompai/comp/commit/cebda99b6f95c000a951acfc392e5b4741b9b1d3)) -* **organization-tasks:** implement task creation with error handling ([ba19e6d](https://github.com/trycompai/comp/commit/ba19e6dae8de772fcbacd654e2a65f89bd340587)) -* **task:** make entityId and entityType optional in Task model ([ad5ecce](https://github.com/trycompai/comp/commit/ad5ecce08941563805fe55a3620e7a34a9cc794c)) +- **migration:** add many-to-many support for tasks ([cebda99](https://github.com/trycompai/comp/commit/cebda99b6f95c000a951acfc392e5b4741b9b1d3)) +- **organization-tasks:** implement task creation with error handling ([ba19e6d](https://github.com/trycompai/comp/commit/ba19e6dae8de772fcbacd654e2a65f89bd340587)) +- **task:** make entityId and entityType optional in Task model ([ad5ecce](https://github.com/trycompai/comp/commit/ad5ecce08941563805fe55a3620e7a34a9cc794c)) # [1.21.0](https://github.com/trycompai/comp/compare/v1.20.0...v1.21.0) (2025-05-15) - ### Features -* added ability to link and unlink policies to controls from the UI ([1d9ace1](https://github.com/trycompai/comp/commit/1d9ace198edd9b4786d69378ac4af56b02782e22)) +- added ability to link and unlink policies to controls from the UI ([1d9ace1](https://github.com/trycompai/comp/commit/1d9ace198edd9b4786d69378ac4af56b02782e22)) # [1.20.0](https://github.com/trycompai/comp/compare/v1.19.0...v1.20.0) (2025-05-15) - ### Features -* **trust-portal:** implement friendly URL functionality ([7e43c73](https://github.com/trycompai/comp/commit/7e43c73e000a3a4dd43885c8999bb95f49a75991)) +- **trust-portal:** implement friendly URL functionality ([7e43c73](https://github.com/trycompai/comp/commit/7e43c73e000a3a4dd43885c8999bb95f49a75991)) # [1.19.0](https://github.com/trycompai/comp/compare/v1.18.0...v1.19.0) (2025-05-14) - ### Features -* added attachments to vendors and risks, also updated the comments component to a better one ([584f01c](https://github.com/trycompai/comp/commit/584f01c09ce9da5a26fa84d400d509fecc995afa)) +- added attachments to vendors and risks, also updated the comments component to a better one ([584f01c](https://github.com/trycompai/comp/commit/584f01c09ce9da5a26fa84d400d509fecc995afa)) # [1.18.0](https://github.com/trycompai/comp/compare/v1.17.0...v1.18.0) (2025-05-14) - ### Features -* **editor:** add custom action cell to ControlsClientPage for navigation ([d8337ff](https://github.com/trycompai/comp/commit/d8337ff7371308e9a4473370a920fe4c8921533b)) -* **editor:** enhance ControlsClientPage with createdAt and updatedAt fields ([5f2b4d6](https://github.com/trycompai/comp/commit/5f2b4d69eaa37182e5733ca452320845d8ee8ed7)) -* **editor:** enhance ControlsClientPage with friendly date formatting and UI improvements ([960fd3c](https://github.com/trycompai/comp/commit/960fd3cd5fedde952c18e855281acd2ed155ea44)) -* **editor:** enhance ControlsClientPage with improved change tracking and UI feedback ([f680dff](https://github.com/trycompai/comp/commit/f680dff7894961c6d02df0071088d4ad02fe2d43)) -* **editor:** enhance ControlsClientPage with improved search and sorting UI ([47db2bd](https://github.com/trycompai/comp/commit/47db2bdf9a02dd78c87b788b241f8e73fd99ea61)) -* **editor:** enhance ControlsClientPage with new control creation and linking features ([8e23222](https://github.com/trycompai/comp/commit/8e23222c195364499e85dd9e5d9b6489bb47fd26)) -* **editor:** enhance ControlsClientPage with relational linking and UI improvements ([275b09e](https://github.com/trycompai/comp/commit/275b09e33d269a22f33e35061e12eaf0b0ace781)) -* **editor:** enhance ControlsClientPage with search and sorting functionality ([05efcb0](https://github.com/trycompai/comp/commit/05efcb0a3043b877cee04677261b6b1de105107b)) -* **editor:** implement change tracking and row styling in ControlsClientPage ([3908e32](https://github.com/trycompai/comp/commit/3908e32075b6eda57bafe5406880dc603b7a5fc1)) -* **editor:** integrate react-datasheet-grid for enhanced controls management ([084e861](https://github.com/trycompai/comp/commit/084e861b0b0efc06c2e34fec3ffc66ccd4eee337)) +- **editor:** add custom action cell to ControlsClientPage for navigation ([d8337ff](https://github.com/trycompai/comp/commit/d8337ff7371308e9a4473370a920fe4c8921533b)) +- **editor:** enhance ControlsClientPage with createdAt and updatedAt fields ([5f2b4d6](https://github.com/trycompai/comp/commit/5f2b4d69eaa37182e5733ca452320845d8ee8ed7)) +- **editor:** enhance ControlsClientPage with friendly date formatting and UI improvements ([960fd3c](https://github.com/trycompai/comp/commit/960fd3cd5fedde952c18e855281acd2ed155ea44)) +- **editor:** enhance ControlsClientPage with improved change tracking and UI feedback ([f680dff](https://github.com/trycompai/comp/commit/f680dff7894961c6d02df0071088d4ad02fe2d43)) +- **editor:** enhance ControlsClientPage with improved search and sorting UI ([47db2bd](https://github.com/trycompai/comp/commit/47db2bdf9a02dd78c87b788b241f8e73fd99ea61)) +- **editor:** enhance ControlsClientPage with new control creation and linking features ([8e23222](https://github.com/trycompai/comp/commit/8e23222c195364499e85dd9e5d9b6489bb47fd26)) +- **editor:** enhance ControlsClientPage with relational linking and UI improvements ([275b09e](https://github.com/trycompai/comp/commit/275b09e33d269a22f33e35061e12eaf0b0ace781)) +- **editor:** enhance ControlsClientPage with search and sorting functionality ([05efcb0](https://github.com/trycompai/comp/commit/05efcb0a3043b877cee04677261b6b1de105107b)) +- **editor:** implement change tracking and row styling in ControlsClientPage ([3908e32](https://github.com/trycompai/comp/commit/3908e32075b6eda57bafe5406880dc603b7a5fc1)) +- **editor:** integrate react-datasheet-grid for enhanced controls management ([084e861](https://github.com/trycompai/comp/commit/084e861b0b0efc06c2e34fec3ffc66ccd4eee337)) # [1.17.0](https://github.com/trycompai/comp/compare/v1.16.0...v1.17.0) (2025-05-14) - ### Features -* **trust-portal:** add Vercel domain verification and enhance trust portal settings ([0f41e99](https://github.com/trycompai/comp/commit/0f41e997bcbee0ae5d9379a3d2b7f75b061766a4)) +- **trust-portal:** add Vercel domain verification and enhance trust portal settings ([0f41e99](https://github.com/trycompai/comp/commit/0f41e997bcbee0ae5d9379a3d2b7f75b061766a4)) # [1.16.0](https://github.com/trycompai/comp/compare/v1.15.0...v1.16.0) (2025-05-14) - ### Bug Fixes -* add empty states & guides ([ba7d11d](https://github.com/trycompai/comp/commit/ba7d11d938821e7d0f9d385b9a0bdeaa8578bec5)) -* fix issues with deleting integrations ([df22563](https://github.com/trycompai/comp/commit/df22563ea69e27b75af032ec1bf438dcd279d3ba)) -* ui improvements for cloud tests ([1db40d3](https://github.com/trycompai/comp/commit/1db40d30ccd4706d6ae200e3d529a0840f777284)) - +- add empty states & guides ([ba7d11d](https://github.com/trycompai/comp/commit/ba7d11d938821e7d0f9d385b9a0bdeaa8578bec5)) +- fix issues with deleting integrations ([df22563](https://github.com/trycompai/comp/commit/df22563ea69e27b75af032ec1bf438dcd279d3ba)) +- ui improvements for cloud tests ([1db40d3](https://github.com/trycompai/comp/commit/1db40d30ccd4706d6ae200e3d529a0840f777284)) ### Features -* implement ui for cloud tests ([5a92613](https://github.com/trycompai/comp/commit/5a926132a0620fb558fe8400a72b4a874de21213)) +- implement ui for cloud tests ([5a92613](https://github.com/trycompai/comp/commit/5a926132a0620fb558fe8400a72b4a874de21213)) # [1.15.0](https://github.com/trycompai/comp/compare/v1.14.2...v1.15.0) (2025-05-14) - ### Features -* **trust-portal:** enhance trust portal settings and compliance frameworks ([5ba7ba4](https://github.com/trycompai/comp/commit/5ba7ba4cd550b3c84f2b6dfca5258071a2c3016d)) +- **trust-portal:** enhance trust portal settings and compliance frameworks ([5ba7ba4](https://github.com/trycompai/comp/commit/5ba7ba4cd550b3c84f2b6dfca5258071a2c3016d)) ## [1.14.2](https://github.com/trycompai/comp/compare/v1.14.1...v1.14.2) (2025-05-13) - ### Bug Fixes -* fix the vendors table search and pagination ([6808ae1](https://github.com/trycompai/comp/commit/6808ae1ef2689ac6434703e83ca80fe82fa4706b)) +- fix the vendors table search and pagination ([6808ae1](https://github.com/trycompai/comp/commit/6808ae1ef2689ac6434703e83ca80fe82fa4706b)) ## [1.14.1](https://github.com/trycompai/comp/compare/v1.14.0...v1.14.1) (2025-05-13) - ### Bug Fixes -* fixed sorting and filtering on risks table ([985b4b7](https://github.com/trycompai/comp/commit/985b4b7d85a2f4090299be66bc8a4ee676f64594)) +- fixed sorting and filtering on risks table ([985b4b7](https://github.com/trycompai/comp/commit/985b4b7d85a2f4090299be66bc8a4ee676f64594)) # [1.14.0](https://github.com/trycompai/comp/compare/v1.13.2...v1.14.0) (2025-05-12) - ### Bug Fixes -* **editor:** adjust padding in AdvancedEditor component for improved layout ([bb27fba](https://github.com/trycompai/comp/commit/bb27fba9505b0ac0819fb57e1053f169c63909f9)) - +- **editor:** adjust padding in AdvancedEditor component for improved layout ([bb27fba](https://github.com/trycompai/comp/commit/bb27fba9505b0ac0819fb57e1053f169c63909f9)) ### Features -* **policies:** enhance policy management with update and delete functionalities ([954ec4d](https://github.com/trycompai/comp/commit/954ec4d03789225a6d8c115704551895d331c1dc)) -* **policies:** implement policy management features with CRUD functionality ([7b2d2d1](https://github.com/trycompai/comp/commit/7b2d2d1957788794b35ed565b247e9a3d81992da)) +- **policies:** enhance policy management with update and delete functionalities ([954ec4d](https://github.com/trycompai/comp/commit/954ec4d03789225a6d8c115704551895d331c1dc)) +- **policies:** implement policy management features with CRUD functionality ([7b2d2d1](https://github.com/trycompai/comp/commit/7b2d2d1957788794b35ed565b247e9a3d81992da)) ## [1.13.2](https://github.com/trycompai/comp/compare/v1.13.1...v1.13.2) (2025-05-12) - ### Bug Fixes -* fix sign in with magic link sign in when invited to an org ([c634d61](https://github.com/trycompai/comp/commit/c634d615e7b7d53376bd764dbd75cd28e1b85ed3)) +- fix sign in with magic link sign in when invited to an org ([c634d61](https://github.com/trycompai/comp/commit/c634d615e7b7d53376bd764dbd75cd28e1b85ed3)) ## [1.13.1](https://github.com/trycompai/comp/compare/v1.13.0...v1.13.1) (2025-05-12) - ### Bug Fixes -* fix popover by adding pointer events in content ([6e7bce5](https://github.com/trycompai/comp/commit/6e7bce5392951cf1cb48ac665bafc486b577d70e)) +- fix popover by adding pointer events in content ([6e7bce5](https://github.com/trycompai/comp/commit/6e7bce5392951cf1cb48ac665bafc486b577d70e)) # [1.13.0](https://github.com/trycompai/comp/compare/v1.12.0...v1.13.0) (2025-05-12) - ### Features -* **tasks:** implement task management UI with CRUD functionality ([f71cee1](https://github.com/trycompai/comp/commit/f71cee17d76536a373c12262ed926517075c2919)) +- **tasks:** implement task management UI with CRUD functionality ([f71cee1](https://github.com/trycompai/comp/commit/f71cee17d76536a373c12262ed926517075c2919)) # [1.12.0](https://github.com/trycompai/comp/compare/v1.11.0...v1.12.0) (2025-05-12) - ### Features -* **database:** add identifier column to FrameworkEditorRequirement and update migration ([c4dee39](https://github.com/trycompai/comp/commit/c4dee398a08a7c4a9d40582b71d9368d14e1a4f7)) -* **requirements:** add optional identifier field to requirement forms and schemas ([1540457](https://github.com/trycompai/comp/commit/1540457d620c1e202afcc51018aae0c017713e3b)) +- **database:** add identifier column to FrameworkEditorRequirement and update migration ([c4dee39](https://github.com/trycompai/comp/commit/c4dee398a08a7c4a9d40582b71d9368d14e1a4f7)) +- **requirements:** add optional identifier field to requirement forms and schemas ([1540457](https://github.com/trycompai/comp/commit/1540457d620c1e202afcc51018aae0c017713e3b)) # [1.11.0](https://github.com/trycompai/comp/compare/v1.10.0...v1.11.0) (2025-05-11) - ### Bug Fixes -* **trust:** update DNS record slug to use TRUST_PORTAL_PROJECT_ID ([366f9e5](https://github.com/trycompai/comp/commit/366f9e51d7709964ea606b7dca305a7a0e91337b)) - +- **trust:** update DNS record slug to use TRUST_PORTAL_PROJECT_ID ([366f9e5](https://github.com/trycompai/comp/commit/366f9e51d7709964ea606b7dca305a7a0e91337b)) ### Features -* **trust:** add TRUST_PORTAL_PROJECT_ID to environment and update DNS record actions ([a99c7bb](https://github.com/trycompai/comp/commit/a99c7bbb2fc360d16e9426f084c098a779d5d224)) +- **trust:** add TRUST_PORTAL_PROJECT_ID to environment and update DNS record actions ([a99c7bb](https://github.com/trycompai/comp/commit/a99c7bbb2fc360d16e9426f084c098a779d5d224)) # [1.10.0](https://github.com/trycompai/comp/compare/v1.9.0...v1.10.0) (2025-05-11) - ### Features -* **trust:** enhance DNS verification and state management in TrustPortalDomain ([27369ea](https://github.com/trycompai/comp/commit/27369ea8f0d36c378e7ae89a14433a90dc723b93)) +- **trust:** enhance DNS verification and state management in TrustPortalDomain ([27369ea](https://github.com/trycompai/comp/commit/27369ea8f0d36c378e7ae89a14433a90dc723b93)) # [1.9.0](https://github.com/trycompai/comp/compare/v1.8.3...v1.9.0) (2025-05-11) - ### Features -* **trust:** implement custom domain management and DNS verification for trust portal ([d34206c](https://github.com/trycompai/comp/commit/d34206cc8e0ca633d071d34e0fc95ad1994a2cf0)) +- **trust:** implement custom domain management and DNS verification for trust portal ([d34206c](https://github.com/trycompai/comp/commit/d34206cc8e0ca633d071d34e0fc95ad1994a2cf0)) ## [1.8.3](https://github.com/trycompai/comp/compare/v1.8.2...v1.8.3) (2025-05-10) - ### Bug Fixes -* **trust:** update organization ID mapping for security domain in middleware ([2e690b1](https://github.com/trycompai/comp/commit/2e690b1e56da4e82e615b305927a0df9dd8d4e2c)) +- **trust:** update organization ID mapping for security domain in middleware ([2e690b1](https://github.com/trycompai/comp/commit/2e690b1e56da4e82e615b305927a0df9dd8d4e2c)) ## [1.8.2](https://github.com/trycompai/comp/compare/v1.8.1...v1.8.2) (2025-05-10) - ### Bug Fixes -* **trust:** update domain mapping in middleware to include new ngrok domain ([cb8f296](https://github.com/trycompai/comp/commit/cb8f2960eba1f9800e297734c3f6e33a17d76314)) +- **trust:** update domain mapping in middleware to include new ngrok domain ([cb8f296](https://github.com/trycompai/comp/commit/cb8f2960eba1f9800e297734c3f6e33a17d76314)) ## [1.8.1](https://github.com/trycompai/comp/compare/v1.8.0...v1.8.1) (2025-05-10) - ### Bug Fixes -* **trust:** add new domain mapping to organization ID and refine URL rewriting logic in middleware ([f8b2854](https://github.com/trycompai/comp/commit/f8b28545adcce6bc18fdf3b590d2d31b8f857ce1)) +- **trust:** add new domain mapping to organization ID and refine URL rewriting logic in middleware ([f8b2854](https://github.com/trycompai/comp/commit/f8b28545adcce6bc18fdf3b590d2d31b8f857ce1)) # [1.8.0](https://github.com/trycompai/comp/compare/v1.7.0...v1.8.0) (2025-05-10) - ### Bug Fixes -* **trust:** update metadata generation to correctly handle async params and adjust URL format ([52b3d23](https://github.com/trycompai/comp/commit/52b3d2316077abb397bf3c108f4fae620502ceae)) - +- **trust:** update metadata generation to correctly handle async params and adjust URL format ([52b3d23](https://github.com/trycompai/comp/commit/52b3d2316077abb397bf3c108f4fae620502ceae)) ### Features -* **trust:** implement middleware for domain-based organization ID mapping and enhance layout with new font and metadata generation ([6237329](https://github.com/trycompai/comp/commit/62373292c9725eb1bbf05bd81ffc789c30098d41)) +- **trust:** implement middleware for domain-based organization ID mapping and enhance layout with new font and metadata generation ([6237329](https://github.com/trycompai/comp/commit/62373292c9725eb1bbf05bd81ffc789c30098d41)) # [1.7.0](https://github.com/trycompai/comp/compare/v1.6.0...v1.7.0) (2025-05-10) - ### Features -* **trust:** add Trust app configuration and dependencies; refactor Trust Portal settings and remove unused components ([8834b14](https://github.com/trycompai/comp/commit/8834b144046c85670c5beecb6afbd514b7ad4006)) -* **turbo:** add data:build configuration to manage DATABASE_URL and build inputs for enhanced build process ([6f4f1c4](https://github.com/trycompai/comp/commit/6f4f1c4e195ceface8c9aac67204c90282cf377e)) +- **trust:** add Trust app configuration and dependencies; refactor Trust Portal settings and remove unused components ([8834b14](https://github.com/trycompai/comp/commit/8834b144046c85670c5beecb6afbd514b7ad4006)) +- **turbo:** add data:build configuration to manage DATABASE_URL and build inputs for enhanced build process ([6f4f1c4](https://github.com/trycompai/comp/commit/6f4f1c4e195ceface8c9aac67204c90282cf377e)) # [1.6.0](https://github.com/trycompai/comp/compare/v1.5.0...v1.6.0) (2025-05-10) - ### Features -* **turbo:** add trust:build configuration to manage DATABASE_URL and inputs for improved build process ([96435a5](https://github.com/trycompai/comp/commit/96435a53558b7d1dcf8faeaa79514ef1037e70f5)) +- **turbo:** add trust:build configuration to manage DATABASE_URL and inputs for improved build process ([96435a5](https://github.com/trycompai/comp/commit/96435a53558b7d1dcf8faeaa79514ef1037e70f5)) # [1.5.0](https://github.com/trycompai/comp/compare/v1.4.0...v1.5.0) (2025-05-10) - ### Bug Fixes -* **package:** add missing newline at end of file in package.json ([75c0e49](https://github.com/trycompai/comp/commit/75c0e4951c79d9a7a0cfe7c30c075082da1a915d)) -* **trust-portal:** optimize getTrustPortal function by caching session retrieval for improved performance ([4a7cbc5](https://github.com/trycompai/comp/commit/4a7cbc52fbbe593fa1d9d68c897242def373b2f3)) - +- **package:** add missing newline at end of file in package.json ([75c0e49](https://github.com/trycompai/comp/commit/75c0e4951c79d9a7a0cfe7c30c075082da1a915d)) +- **trust-portal:** optimize getTrustPortal function by caching session retrieval for improved performance ([4a7cbc5](https://github.com/trycompai/comp/commit/4a7cbc52fbbe593fa1d9d68c897242def373b2f3)) ### Features -* **trust-portal:** add Trust Portal settings page and components, including loading state and switch functionality; update layout to include Trust Portal link ([3fc5fba](https://github.com/trycompai/comp/commit/3fc5fba9fcf21f55591624858268102698d75b05)) -* **trust-portal:** enhance Next.js configuration and add new components for improved error handling and compliance reporting; update package dependencies ([1e899a4](https://github.com/trycompai/comp/commit/1e899a442174ec78015cef5929446ea6ebcc994e)) -* **trust-portal:** implement TrustPortalSettings component with dynamic trust portal state retrieval and rendering ([4facc5c](https://github.com/trycompai/comp/commit/4facc5c6c2e30ab4afe0333a76382d3136b9c321)) -* **turbo:** add build:trust configuration to manage environment variables and dependencies for improved build process ([c7475e2](https://github.com/trycompai/comp/commit/c7475e26c41f12fab9677e3dda2765feb7881010)) -* **turbo:** rename build:trust to trust:build and add it to the build pipeline for better organization ([95569ae](https://github.com/trycompai/comp/commit/95569ae853fddbfc5f776006fe093c4b672e5c24)) +- **trust-portal:** add Trust Portal settings page and components, including loading state and switch functionality; update layout to include Trust Portal link ([3fc5fba](https://github.com/trycompai/comp/commit/3fc5fba9fcf21f55591624858268102698d75b05)) +- **trust-portal:** enhance Next.js configuration and add new components for improved error handling and compliance reporting; update package dependencies ([1e899a4](https://github.com/trycompai/comp/commit/1e899a442174ec78015cef5929446ea6ebcc994e)) +- **trust-portal:** implement TrustPortalSettings component with dynamic trust portal state retrieval and rendering ([4facc5c](https://github.com/trycompai/comp/commit/4facc5c6c2e30ab4afe0333a76382d3136b9c321)) +- **turbo:** add build:trust configuration to manage environment variables and dependencies for improved build process ([c7475e2](https://github.com/trycompai/comp/commit/c7475e26c41f12fab9677e3dda2765feb7881010)) +- **turbo:** rename build:trust to trust:build and add it to the build pipeline for better organization ([95569ae](https://github.com/trycompai/comp/commit/95569ae853fddbfc5f776006fe093c4b672e5c24)) # [1.4.0](https://github.com/trycompai/comp/compare/v1.3.0...v1.4.0) (2025-05-10) - ### Features -* **controls:** add Edit and Delete Control dialogs for enhanced control management; implement update and delete functionalities in actions ([c05ab4d](https://github.com/trycompai/comp/commit/c05ab4d9faf3654b1c22b479a101f2aac721df22)) -* **controls:** implement control template management features including creation, linking, and unlinking of requirements, policies, and tasks; enhance UI components for better user experience ([779e579](https://github.com/trycompai/comp/commit/779e579ddf5dd3ad86c20e97ecde735a6f7cdccb)) -* **controls:** implement linking and unlinking of policy and task templates to control templates; enhance ManageLinksDialog for improved user interaction ([05da639](https://github.com/trycompai/comp/commit/05da639ccf1cce77b79837ccea4c73bba523ed6e)) -* **loading:** add Loading component with skeleton placeholders for improved user experience; enhance PageLayout to support loading state ([166fa59](https://github.com/trycompai/comp/commit/166fa59d8f8f7ef535d86efeeb340a9aca4243fc)) +- **controls:** add Edit and Delete Control dialogs for enhanced control management; implement update and delete functionalities in actions ([c05ab4d](https://github.com/trycompai/comp/commit/c05ab4d9faf3654b1c22b479a101f2aac721df22)) +- **controls:** implement control template management features including creation, linking, and unlinking of requirements, policies, and tasks; enhance UI components for better user experience ([779e579](https://github.com/trycompai/comp/commit/779e579ddf5dd3ad86c20e97ecde735a6f7cdccb)) +- **controls:** implement linking and unlinking of policy and task templates to control templates; enhance ManageLinksDialog for improved user interaction ([05da639](https://github.com/trycompai/comp/commit/05da639ccf1cce77b79837ccea4c73bba523ed6e)) +- **loading:** add Loading component with skeleton placeholders for improved user experience; enhance PageLayout to support loading state ([166fa59](https://github.com/trycompai/comp/commit/166fa59d8f8f7ef535d86efeeb340a9aca4243fc)) # [1.3.0](https://github.com/trycompai/comp/compare/v1.2.1...v1.3.0) (2025-05-09) - ### Features -* **Providers:** introduce Providers component to wrap RootLayout with NuqsAdapter and Suspense for improved rendering ([aa66614](https://github.com/trycompai/comp/commit/aa666142f951fb062403caba75252a26a58e91bd)) +- **Providers:** introduce Providers component to wrap RootLayout with NuqsAdapter and Suspense for improved rendering ([aa66614](https://github.com/trycompai/comp/commit/aa666142f951fb062403caba75252a26a58e91bd)) ## [1.2.1](https://github.com/trycompai/comp/compare/v1.2.0...v1.2.1) (2025-05-09) - ### Bug Fixes -* **layout:** correct NuqsAdapter placement in RootLayout component for proper rendering ([4d315a0](https://github.com/trycompai/comp/commit/4d315a0bb74a4e29cabf304649797c6fb8ac52b5)) +- **layout:** correct NuqsAdapter placement in RootLayout component for proper rendering ([4d315a0](https://github.com/trycompai/comp/commit/4d315a0bb74a4e29cabf304649797c6fb8ac52b5)) # [1.2.0](https://github.com/trycompai/comp/compare/v1.1.1...v1.2.0) (2025-05-09) - ### Bug Fixes -* **CreateFrameworkDialog:** adjust form layout by reducing gap size and updating version input placeholder for clarity ([ff56470](https://github.com/trycompai/comp/commit/ff5647076d4a79c5564b69cf50bcc331eaf4bc45)) - +- **CreateFrameworkDialog:** adjust form layout by reducing gap size and updating version input placeholder for clarity ([ff56470](https://github.com/trycompai/comp/commit/ff5647076d4a79c5564b69cf50bcc331eaf4bc45)) ### Features -* add database migrations and update Prisma schema for framework editor ([3287353](https://github.com/trycompai/comp/commit/32873533a38e29fcec7d4102cb6d233fd70e0c56)) -* add RequirementBaseSchema for requirement validation; implement EditRequirementDialog for editing requirements with form handling and server action integration ([2e1e91e](https://github.com/trycompai/comp/commit/2e1e91e7679005c9d3719f18e144fa70f59f5b77)) -* enhance framework editor layout with Toolbar and MenuTabs components ([21566bb](https://github.com/trycompai/comp/commit/21566bb8adb44d1498c91a185e0084cdc98dfef4)) -* enhance framework-editor layout by restructuring RootLayout for full-height body and adding breadcrumbs to PageLayout for controls and policies pages; implement FrameworksClientPage for improved framework management ([2400d35](https://github.com/trycompai/comp/commit/2400d3588eff54a78c369dcddf50cd1037c6cc42)) -* enhance FrameworkRequirementsClientPage with requirement editing functionality; refactor columns to use dynamic column generation and improve DataTable integration for better user experience ([1db3b8f](https://github.com/trycompai/comp/commit/1db3b8f9019892b6bf090fa35dc1acb72da14e82)) -* fetch and display frameworks in framework-editor page ([b9db4b9](https://github.com/trycompai/comp/commit/b9db4b90b5374acf1e08865eb6b22fad2b8774e2)) -* **FrameworkRequirementsClientPage:** add delete functionality with confirmation dialog for framework deletion ([91a9b27](https://github.com/trycompai/comp/commit/91a9b27eaff58bc7be9ec3b8238983d48867fe7f)) -* implement add and delete requirement functionality with corresponding dialogs; enhance FrameworkRequirementsClientPage for better user experience and data management ([0efa400](https://github.com/trycompai/comp/commit/0efa400d7b5687e3d7993110a3c686e70dc29a8f)) -* implement DataTable component for enhanced data display and search functionality across controls, frameworks, policies, and tasks pages; update layout with NuqsAdapter and Toaster for improved user experience ([a74dfca](https://github.com/trycompai/comp/commit/a74dfca1aa63409ee07b42e45bedc9a10f0590f4)) -* implement delete framework functionality with confirmation dialog and server action integration ([5bdbeb1](https://github.com/trycompai/comp/commit/5bdbeb1554512009595e3b92da43dbe19f58b445)) -* implement FrameworkRequirementsClientPage and enhance framework data handling; update FrameworksClientPage to include counts for requirements and controls, and improve DataTable with row click functionality ([4151d8f](https://github.com/trycompai/comp/commit/4151d8f4554be8b028957a0679e8b00aef07401b)) -* introduce FrameworkBaseSchema for consistent framework validation; refactor framework actions and dialogs to utilize shared schema for improved maintainability ([066efcc](https://github.com/trycompai/comp/commit/066efcc49899d51cd1d1296a12c6f5419a182fec)) -* update framework-editor with new columns for DataTable across controls, frameworks, policies, and tasks pages; enhance data fetching and layout for improved user experience ([034f75e](https://github.com/trycompai/comp/commit/034f75e1ac374fd845d78cd9901769bac1f658f4)) +- add database migrations and update Prisma schema for framework editor ([3287353](https://github.com/trycompai/comp/commit/32873533a38e29fcec7d4102cb6d233fd70e0c56)) +- add RequirementBaseSchema for requirement validation; implement EditRequirementDialog for editing requirements with form handling and server action integration ([2e1e91e](https://github.com/trycompai/comp/commit/2e1e91e7679005c9d3719f18e144fa70f59f5b77)) +- enhance framework editor layout with Toolbar and MenuTabs components ([21566bb](https://github.com/trycompai/comp/commit/21566bb8adb44d1498c91a185e0084cdc98dfef4)) +- enhance framework-editor layout by restructuring RootLayout for full-height body and adding breadcrumbs to PageLayout for controls and policies pages; implement FrameworksClientPage for improved framework management ([2400d35](https://github.com/trycompai/comp/commit/2400d3588eff54a78c369dcddf50cd1037c6cc42)) +- enhance FrameworkRequirementsClientPage with requirement editing functionality; refactor columns to use dynamic column generation and improve DataTable integration for better user experience ([1db3b8f](https://github.com/trycompai/comp/commit/1db3b8f9019892b6bf090fa35dc1acb72da14e82)) +- fetch and display frameworks in framework-editor page ([b9db4b9](https://github.com/trycompai/comp/commit/b9db4b90b5374acf1e08865eb6b22fad2b8774e2)) +- **FrameworkRequirementsClientPage:** add delete functionality with confirmation dialog for framework deletion ([91a9b27](https://github.com/trycompai/comp/commit/91a9b27eaff58bc7be9ec3b8238983d48867fe7f)) +- implement add and delete requirement functionality with corresponding dialogs; enhance FrameworkRequirementsClientPage for better user experience and data management ([0efa400](https://github.com/trycompai/comp/commit/0efa400d7b5687e3d7993110a3c686e70dc29a8f)) +- implement DataTable component for enhanced data display and search functionality across controls, frameworks, policies, and tasks pages; update layout with NuqsAdapter and Toaster for improved user experience ([a74dfca](https://github.com/trycompai/comp/commit/a74dfca1aa63409ee07b42e45bedc9a10f0590f4)) +- implement delete framework functionality with confirmation dialog and server action integration ([5bdbeb1](https://github.com/trycompai/comp/commit/5bdbeb1554512009595e3b92da43dbe19f58b445)) +- implement FrameworkRequirementsClientPage and enhance framework data handling; update FrameworksClientPage to include counts for requirements and controls, and improve DataTable with row click functionality ([4151d8f](https://github.com/trycompai/comp/commit/4151d8f4554be8b028957a0679e8b00aef07401b)) +- introduce FrameworkBaseSchema for consistent framework validation; refactor framework actions and dialogs to utilize shared schema for improved maintainability ([066efcc](https://github.com/trycompai/comp/commit/066efcc49899d51cd1d1296a12c6f5419a182fec)) +- update framework-editor with new columns for DataTable across controls, frameworks, policies, and tasks pages; enhance data fetching and layout for improved user experience ([034f75e](https://github.com/trycompai/comp/commit/034f75e1ac374fd845d78cd9901769bac1f658f4)) ## [1.1.1](https://github.com/trycompai/comp/compare/v1.1.0...v1.1.1) (2025-05-09) - ### Bug Fixes -* **organization:** enhance user name handling in createOrganizationAction and update newOrgSequence email content ([1f8a68a](https://github.com/trycompai/comp/commit/1f8a68a3d3223b5b8faec9872a1fe52d40b286bf)) +- **organization:** enhance user name handling in createOrganizationAction and update newOrgSequence email content ([1f8a68a](https://github.com/trycompai/comp/commit/1f8a68a3d3223b5b8faec9872a1fe52d40b286bf)) # [1.1.0](https://github.com/trycompai/comp/compare/v1.0.1...v1.1.0) (2025-05-09) - ### Bug Fixes -* **package:** add missing newline at end of file in package.json ([99ee59c](https://github.com/trycompai/comp/commit/99ee59cfa00bbc08efc14fa06bc5e9f0c3d3a51a)) -* **package:** remove trailing newline in package.json ([41d024d](https://github.com/trycompai/comp/commit/41d024d3707e4b5b3f3a0e6cb097cfc722427329)) - +- **package:** add missing newline at end of file in package.json ([99ee59c](https://github.com/trycompai/comp/commit/99ee59cfa00bbc08efc14fa06bc5e9f0c3d3a51a)) +- **package:** remove trailing newline in package.json ([41d024d](https://github.com/trycompai/comp/commit/41d024d3707e4b5b3f3a0e6cb097cfc722427329)) ### Features -* implement new organization welcome email sequence and remove legacy email component ([5173aa0](https://github.com/trycompai/comp/commit/5173aa044af64173308a0ea53c8d654dae0a9f45)) +- implement new organization welcome email sequence and remove legacy email component ([5173aa0](https://github.com/trycompai/comp/commit/5173aa044af64173308a0ea53c8d654dae0a9f45)) ## [1.0.1](https://github.com/trycompai/comp/compare/v1.0.0...v1.0.1) (2025-05-03) - ### Bug Fixes -* **docs:** add missing period at the end of README.md tip for clarity ([245bb5f](https://github.com/trycompai/comp/commit/245bb5f18c3849da319b43bd71b4490c166fac33)) -* **docs:** remove newline at end of README.md for consistency ([73b81fd](https://github.com/trycompai/comp/commit/73b81fd052bb6a2e88fd021b3fc0d4134330652c)) +- **docs:** add missing period at the end of README.md tip for clarity ([245bb5f](https://github.com/trycompai/comp/commit/245bb5f18c3849da319b43bd71b4490c166fac33)) +- **docs:** remove newline at end of README.md for consistency ([73b81fd](https://github.com/trycompai/comp/commit/73b81fd052bb6a2e88fd021b3fc0d4134330652c)) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c4667f688c..4424ca6e31 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -96,7 +96,6 @@ To develop locally: - Duplicate `.env.example` to `.env`. - Use `openssl rand -base64 32` to generate a key and add it under `SECRET_KEY` in the `.env` file. - - Setup Trigger.dev - CD into apps/app and run `bunx trigger.dev@latest login`, then `bunx trigger.dev@latest dev` - Use `openssl rand -base64 32` to generate a key and add it under `TRIGGER_SECRET_KEY` in the `.env` file. @@ -143,4 +142,4 @@ If you get errors, be sure to fix them before committing. - If your PR refers to or fixes an issue, be sure to add `refs #XXX` or `fixes #XXX` to the PR description. Replacing `XXX` with the respective issue number. See more about [Linking a pull request to an issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue). - Be sure to fill the PR Template accordingly. -Lastly, make sure to keep the branches updated (e.g. click the `Update branch` button on GitHub PR). \ No newline at end of file +Lastly, make sure to keep the branches updated (e.g. click the `Update branch` button on GitHub PR). diff --git a/README.md b/README.md index 79b84157f3..b9d9a042a2 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ We transform compliance from a vendor checkbox into an engineering problem solve Comp AI - The open source Vanta & Drata alternative | Product Hunt ### [Vercel](https://vercel.com) +
Vercel OSS Program diff --git a/SECURITY.md b/SECURITY.md index 6a0c9fdb42..a3338add59 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -54,4 +54,4 @@ better protect our clients and our systems. name as the discoverer of the problem (unless you desire otherwise), and - We strive to resolve all problems as quickly as possible, and we would like to play an active role in the ultimate publication on the problem after it - is resolved. \ No newline at end of file + is resolved. diff --git a/apps/app/.eslintrc.json b/apps/app/.eslintrc.json new file mode 100644 index 0000000000..25cc3edfd0 --- /dev/null +++ b/apps/app/.eslintrc.json @@ -0,0 +1,9 @@ +{ + "extends": "next/core-web-vitals", + "rules": { + "react/no-unescaped-entities": "off", + "react-hooks/exhaustive-deps": "warn", + "react-hooks/rules-of-hooks": "warn", + "react/display-name": "off" + } +} diff --git a/apps/app/customPrismaExtension.ts b/apps/app/customPrismaExtension.ts index 1ecbda3404..6da093a8a7 100644 --- a/apps/app/customPrismaExtension.ts +++ b/apps/app/customPrismaExtension.ts @@ -1,8 +1,8 @@ import { BuildManifest } from "@trigger.dev/build"; import { - binaryForRuntime, - BuildContext, - BuildExtension, + binaryForRuntime, + BuildContext, + BuildExtension, } from "@trigger.dev/build"; import assert from "node:assert"; import { existsSync, statSync } from "node:fs"; @@ -10,285 +10,276 @@ import { cp, readdir } from "node:fs/promises"; import { dirname, join, resolve } from "node:path"; export type PrismaExtensionOptions = { - schema: string; - migrate?: boolean; - version?: string; - /** - * Adds the `--sql` flag to the `prisma generate` command. This will generate the SQL files for the Prisma schema. Requires the `typedSql preview feature and prisma 5.19.0 or later. - */ - typedSql?: boolean; - /** - * The client generator to use. Set this param to prevent all generators in the prisma schema from being generated. - * - * @example - * - * ### Prisma schema - * - * ```prisma - * generator client { - * provider = "prisma-client-js" - * } - * - * generator typegraphql { - * provider = "typegraphql-prisma" - * output = "./generated/type-graphql" - * } - * ``` - * - * ### PrismaExtension - * - * ```ts - * prismaExtension({ - * schema: "./prisma/schema.prisma", - * clientGenerator: "client" - * }); - * ``` - */ - clientGenerator?: string; - directUrlEnvVarName?: string; - isUsingSchemaFolder?: boolean; + schema: string; + migrate?: boolean; + version?: string; + /** + * Adds the `--sql` flag to the `prisma generate` command. This will generate the SQL files for the Prisma schema. Requires the `typedSql preview feature and prisma 5.19.0 or later. + */ + typedSql?: boolean; + /** + * The client generator to use. Set this param to prevent all generators in the prisma schema from being generated. + * + * @example + * + * ### Prisma schema + * + * ```prisma + * generator client { + * provider = "prisma-client-js" + * } + * + * generator typegraphql { + * provider = "typegraphql-prisma" + * output = "./generated/type-graphql" + * } + * ``` + * + * ### PrismaExtension + * + * ```ts + * prismaExtension({ + * schema: "./prisma/schema.prisma", + * clientGenerator: "client" + * }); + * ``` + */ + clientGenerator?: string; + directUrlEnvVarName?: string; + isUsingSchemaFolder?: boolean; }; const BINARY_TARGET = "linux-arm64-openssl-3.0.x"; export function prismaExtension( - options: PrismaExtensionOptions, + options: PrismaExtensionOptions, ): PrismaExtension { - return new PrismaExtension(options); + return new PrismaExtension(options); } export class PrismaExtension implements BuildExtension { - moduleExternals: string[]; - public readonly name = "PrismaExtension"; - private _resolvedSchemaPath?: string; - constructor(private options: PrismaExtensionOptions) { - this.moduleExternals = ["@prisma/client", "@prisma/engines"]; - } - externalsForTarget(target: any) { - if (target === "dev") { - return []; - } - return this.moduleExternals; - } - async onBuildStart(context: BuildContext) { - if (context.target === "dev") { - return; - } - // Resolve the path to the prisma schema, relative to the config.directory - let resolvedPath = resolve(context.workingDir, this.options.schema); + moduleExternals: string[]; + public readonly name = "PrismaExtension"; + private _resolvedSchemaPath?: string; + constructor(private options: PrismaExtensionOptions) { + this.moduleExternals = ["@prisma/client", "@prisma/engines"]; + } + externalsForTarget(target: any) { + if (target === "dev") { + return []; + } + return this.moduleExternals; + } + async onBuildStart(context: BuildContext) { + if (context.target === "dev") { + return; + } + // Resolve the path to the prisma schema, relative to the config.directory + let resolvedPath = resolve(context.workingDir, this.options.schema); - // Check if it's a directory; if so, look for schema.prisma inside - if (statSync(resolvedPath).isDirectory()) { - resolvedPath = join(resolvedPath, "schema.prisma"); - this.options.isUsingSchemaFolder = true; - } - this._resolvedSchemaPath = resolvedPath; - context.logger.debug( - `Resolved the prisma schema to: ${this._resolvedSchemaPath}`, - ); - // Check that the prisma schema exists - if (!existsSync(this._resolvedSchemaPath)) { - throw new Error( - `PrismaExtension could not find the prisma schema at ${this._resolvedSchemaPath}. Make sure the path is correct: ${this.options.schema}, relative to the working dir ${context.workingDir}`, - ); - } - } - async onBuildComplete(context: BuildContext, manifest: BuildManifest) { - if (context.target === "dev") { - return; - } - assert(this._resolvedSchemaPath, "Resolved schema path is not set"); - context.logger.debug("Looking for @prisma/client in the externals", { - externals: manifest.externals, - }); - const prismaExternal = manifest.externals?.find( - (external) => external.name === "@prisma/client", - ); - const version = prismaExternal?.version ?? this.options.version; - if (!version) { - throw new Error( - `PrismaExtension could not determine the version of @prisma/client. It's possible that the @prisma/client was not used in the project. If this isn't the case, please provide a version in the PrismaExtension options.`, - ); - } - context.logger.debug( - `PrismaExtension is generating the Prisma client for version ${version}`, - ); - const usingSchemaFolder = this.options.isUsingSchemaFolder; - const commands: string[] = []; - let prismaDir: string | undefined; - const generatorFlags: string[] = []; - if (this.options.clientGenerator) { - generatorFlags.push(`--generator=${this.options.clientGenerator}`); - } - if (this.options.typedSql) { - generatorFlags.push("--sql"); - const prismaDir = usingSchemaFolder - ? dirname(dirname(this._resolvedSchemaPath)) - : dirname(this._resolvedSchemaPath); - context.logger.debug("Using typedSql"); - // Find all the files prisma/sql/*.sql - const sqlFiles = await readdir(join(prismaDir, "sql")).then( - (files) => files.filter((file) => file.endsWith(".sql")), - ); - context.logger.debug("Found sql files", { - sqlFiles, - }); - const sqlDestinationPath = join( - manifest.outputPath, - "prisma", - "sql", - ); - for (const file of sqlFiles) { - const destination = join(sqlDestinationPath, file); - const source = join(prismaDir, "sql", file); - context.logger.debug( - `Copying the sql from ${source} to ${destination}`, - ); - await cp(source, destination); - } - } - if (usingSchemaFolder) { - const schemaDir = dirname(this._resolvedSchemaPath); - prismaDir = dirname(schemaDir); - context.logger.debug(`Using the schema folder: ${schemaDir}`); - // Find all the files in schemaDir that end with .prisma (excluding the schema.prisma file) - const prismaFiles = await readdir(schemaDir).then((files) => - files.filter((file) => file.endsWith(".prisma")), - ); - context.logger.debug("Found prisma files in the schema folder", { - prismaFiles, - }); - const schemaDestinationPath = join( - manifest.outputPath, - "prisma", - "schema", - ); - const allPrismaFiles = [...prismaFiles]; + // Check if it's a directory; if so, look for schema.prisma inside + if (statSync(resolvedPath).isDirectory()) { + resolvedPath = join(resolvedPath, "schema.prisma"); + this.options.isUsingSchemaFolder = true; + } + this._resolvedSchemaPath = resolvedPath; + context.logger.debug( + `Resolved the prisma schema to: ${this._resolvedSchemaPath}`, + ); + // Check that the prisma schema exists + if (!existsSync(this._resolvedSchemaPath)) { + throw new Error( + `PrismaExtension could not find the prisma schema at ${this._resolvedSchemaPath}. Make sure the path is correct: ${this.options.schema}, relative to the working dir ${context.workingDir}`, + ); + } + } + async onBuildComplete(context: BuildContext, manifest: BuildManifest) { + if (context.target === "dev") { + return; + } + assert(this._resolvedSchemaPath, "Resolved schema path is not set"); + context.logger.debug("Looking for @prisma/client in the externals", { + externals: manifest.externals, + }); + const prismaExternal = manifest.externals?.find( + (external) => external.name === "@prisma/client", + ); + const version = prismaExternal?.version ?? this.options.version; + if (!version) { + throw new Error( + `PrismaExtension could not determine the version of @prisma/client. It's possible that the @prisma/client was not used in the project. If this isn't the case, please provide a version in the PrismaExtension options.`, + ); + } + context.logger.debug( + `PrismaExtension is generating the Prisma client for version ${version}`, + ); + const usingSchemaFolder = this.options.isUsingSchemaFolder; + const commands: string[] = []; + let prismaDir: string | undefined; + const generatorFlags: string[] = []; + if (this.options.clientGenerator) { + generatorFlags.push(`--generator=${this.options.clientGenerator}`); + } + if (this.options.typedSql) { + generatorFlags.push("--sql"); + const prismaDir = usingSchemaFolder + ? dirname(dirname(this._resolvedSchemaPath)) + : dirname(this._resolvedSchemaPath); + context.logger.debug("Using typedSql"); + // Find all the files prisma/sql/*.sql + const sqlFiles = await readdir(join(prismaDir, "sql")).then((files) => + files.filter((file) => file.endsWith(".sql")), + ); + context.logger.debug("Found sql files", { + sqlFiles, + }); + const sqlDestinationPath = join(manifest.outputPath, "prisma", "sql"); + for (const file of sqlFiles) { + const destination = join(sqlDestinationPath, file); + const source = join(prismaDir, "sql", file); + context.logger.debug( + `Copying the sql from ${source} to ${destination}`, + ); + await cp(source, destination); + } + } + if (usingSchemaFolder) { + const schemaDir = dirname(this._resolvedSchemaPath); + prismaDir = dirname(schemaDir); + context.logger.debug(`Using the schema folder: ${schemaDir}`); + // Find all the files in schemaDir that end with .prisma (excluding the schema.prisma file) + const prismaFiles = await readdir(schemaDir).then((files) => + files.filter((file) => file.endsWith(".prisma")), + ); + context.logger.debug("Found prisma files in the schema folder", { + prismaFiles, + }); + const schemaDestinationPath = join( + manifest.outputPath, + "prisma", + "schema", + ); + const allPrismaFiles = [...prismaFiles]; - // --- NEW: Look for a 'schema' subfolder and collect .prisma files from it --- - const schemaSubDir = join(schemaDir, "schema"); - let subDirPrismaFiles: string[] = []; - try { - const filesInSubDir = await readdir(schemaSubDir); - subDirPrismaFiles = filesInSubDir.filter((file) => - file.endsWith(".prisma"), - ); - context.logger.debug( - "Found prisma files in the schema subfolder", - { - subDirPrismaFiles, - }, - ); - } catch (err) { - // Ignore if the subdirectory does not exist - context.logger.debug( - `No 'schema' subfolder found in ${schemaDir}`, - ); - } + // --- NEW: Look for a 'schema' subfolder and collect .prisma files from it --- + const schemaSubDir = join(schemaDir, "schema"); + let subDirPrismaFiles: string[] = []; + try { + const filesInSubDir = await readdir(schemaSubDir); + subDirPrismaFiles = filesInSubDir.filter((file) => + file.endsWith(".prisma"), + ); + context.logger.debug("Found prisma files in the schema subfolder", { + subDirPrismaFiles, + }); + } catch (err) { + // Ignore if the subdirectory does not exist + context.logger.debug(`No 'schema' subfolder found in ${schemaDir}`); + } - // Copy top-level .prisma files - for (const file of allPrismaFiles) { - const destination = join(schemaDestinationPath, file); - const source = join(schemaDir, file); - context.logger.debug( - `Copying the prisma schema from ${source} to ${destination}`, - ); - await cp(source, destination); - } - // Copy .prisma files from schema subdirectory - for (const file of subDirPrismaFiles) { - const destination = join(schemaDestinationPath, file); - const source = join(schemaSubDir, file); - context.logger.debug( - `Copying the prisma schema from ${source} to ${destination}`, - ); - await cp(source, destination); - } - commands.push( - `${binaryForRuntime( - manifest.runtime, - )} node_modules/prisma/build/index.js generate --schema=./prisma/schema ${generatorFlags.join( - " ", - )}`, - ); - } else { - prismaDir = dirname(this._resolvedSchemaPath); - // Now we need to add a layer that: - // Copies the prisma schema to the build outputPath - // Adds the `prisma` CLI dependency to the dependencies - // Adds the `prisma generate` command, which generates the Prisma client - const schemaDestinationPath = join( - manifest.outputPath, - "prisma", - "schema.prisma", - ); - // Copy the prisma schema to the build output path - context.logger.debug( - `Copying the prisma schema from ${this._resolvedSchemaPath} to ${schemaDestinationPath}`, - ); - await cp(this._resolvedSchemaPath, schemaDestinationPath); - commands.push( - `${binaryForRuntime( - manifest.runtime, - )} node_modules/prisma/build/index.js generate --schema=./prisma/schema ${generatorFlags.join( - " ", - )}`, - ); - } - const env: Record = {}; - if (this.options.migrate) { - // Copy the migrations directory to the build output path - const migrationsDir = join(prismaDir, "migrations"); - const migrationsDestinationPath = join( - manifest.outputPath, - "prisma", - "migrations", - ); - context.logger.debug( - `Copying the prisma migrations from ${migrationsDir} to ${migrationsDestinationPath}`, - ); - await cp(migrationsDir, migrationsDestinationPath, { - recursive: true, - }); - commands.push( - `${binaryForRuntime(manifest.runtime)} node_modules/prisma/build/index.js migrate deploy`, - ); - } - env.DATABASE_URL = manifest.deploy.env?.DATABASE_URL; - if (this.options.directUrlEnvVarName) { - env[this.options.directUrlEnvVarName] = - manifest.deploy.env?.[this.options.directUrlEnvVarName] ?? - process.env[this.options.directUrlEnvVarName]; - if (!env[this.options.directUrlEnvVarName]) { - context.logger.warn( - `prismaExtension could not resolve the ${this.options.directUrlEnvVarName} environment variable. Make sure you add it to your environment variables or provide it as an environment variable to the deploy CLI command. See our docs for more info: https://trigger.dev/docs/deploy-environment-variables`, - ); - } - } else { - env.DIRECT_URL = manifest.deploy.env?.DIRECT_URL; - env.DIRECT_DATABASE_URL = manifest.deploy.env?.DIRECT_DATABASE_URL; - } - if (!env.DATABASE_URL) { - context.logger.warn( - "prismaExtension could not resolve the DATABASE_URL environment variable. Make sure you add it to your environment variables. See our docs for more info: https://trigger.dev/docs/deploy-environment-variables", - ); - } - context.logger.debug( - "Adding the prisma layer with the following commands", - { - commands, - env, - dependencies: { - prisma: version, - }, - }, - ); - context.addLayer({ - id: "prisma", - commands, - dependencies: { - prisma: version, - }, - build: { - env, - }, - }); - } + // Copy top-level .prisma files + for (const file of allPrismaFiles) { + const destination = join(schemaDestinationPath, file); + const source = join(schemaDir, file); + context.logger.debug( + `Copying the prisma schema from ${source} to ${destination}`, + ); + await cp(source, destination); + } + // Copy .prisma files from schema subdirectory + for (const file of subDirPrismaFiles) { + const destination = join(schemaDestinationPath, file); + const source = join(schemaSubDir, file); + context.logger.debug( + `Copying the prisma schema from ${source} to ${destination}`, + ); + await cp(source, destination); + } + commands.push( + `${binaryForRuntime( + manifest.runtime, + )} node_modules/prisma/build/index.js generate --schema=./prisma/schema ${generatorFlags.join( + " ", + )}`, + ); + } else { + prismaDir = dirname(this._resolvedSchemaPath); + // Now we need to add a layer that: + // Copies the prisma schema to the build outputPath + // Adds the `prisma` CLI dependency to the dependencies + // Adds the `prisma generate` command, which generates the Prisma client + const schemaDestinationPath = join( + manifest.outputPath, + "prisma", + "schema.prisma", + ); + // Copy the prisma schema to the build output path + context.logger.debug( + `Copying the prisma schema from ${this._resolvedSchemaPath} to ${schemaDestinationPath}`, + ); + await cp(this._resolvedSchemaPath, schemaDestinationPath); + commands.push( + `${binaryForRuntime( + manifest.runtime, + )} node_modules/prisma/build/index.js generate --schema=./prisma/schema ${generatorFlags.join( + " ", + )}`, + ); + } + const env: Record = {}; + if (this.options.migrate) { + // Copy the migrations directory to the build output path + const migrationsDir = join(prismaDir, "migrations"); + const migrationsDestinationPath = join( + manifest.outputPath, + "prisma", + "migrations", + ); + context.logger.debug( + `Copying the prisma migrations from ${migrationsDir} to ${migrationsDestinationPath}`, + ); + await cp(migrationsDir, migrationsDestinationPath, { + recursive: true, + }); + commands.push( + `${binaryForRuntime(manifest.runtime)} node_modules/prisma/build/index.js migrate deploy`, + ); + } + env.DATABASE_URL = manifest.deploy.env?.DATABASE_URL; + if (this.options.directUrlEnvVarName) { + env[this.options.directUrlEnvVarName] = + manifest.deploy.env?.[this.options.directUrlEnvVarName] ?? + process.env[this.options.directUrlEnvVarName]; + if (!env[this.options.directUrlEnvVarName]) { + context.logger.warn( + `prismaExtension could not resolve the ${this.options.directUrlEnvVarName} environment variable. Make sure you add it to your environment variables or provide it as an environment variable to the deploy CLI command. See our docs for more info: https://trigger.dev/docs/deploy-environment-variables`, + ); + } + } else { + env.DIRECT_URL = manifest.deploy.env?.DIRECT_URL; + env.DIRECT_DATABASE_URL = manifest.deploy.env?.DIRECT_DATABASE_URL; + } + if (!env.DATABASE_URL) { + context.logger.warn( + "prismaExtension could not resolve the DATABASE_URL environment variable. Make sure you add it to your environment variables. See our docs for more info: https://trigger.dev/docs/deploy-environment-variables", + ); + } + context.logger.debug( + "Adding the prisma layer with the following commands", + { + commands, + env, + dependencies: { + prisma: version, + }, + }, + ); + context.addLayer({ + id: "prisma", + commands, + dependencies: { + prisma: version, + }, + build: { + env, + }, + }); + } } diff --git a/apps/app/next.config.ts b/apps/app/next.config.ts index e40fb7b81a..99428616c0 100644 --- a/apps/app/next.config.ts +++ b/apps/app/next.config.ts @@ -2,51 +2,51 @@ import "./src/env.mjs"; import type { NextConfig } from "next"; const config: NextConfig = { - poweredByHeader: false, - reactStrictMode: true, - turbopack: { - resolveAlias: { - underscore: "lodash", - }, - }, - images: { - remotePatterns: [ - { - protocol: "https", - hostname: "**", - }, - ], - }, - transpilePackages: ["@comp/ui"], - logging: { - fetches: { - fullUrl: process.env.LOG_FETCHES === "true", - }, - }, - experimental: { - serverActions: { - bodySizeLimit: "15mb", - }, - nodeMiddleware: true, - authInterrupts: true, - }, - async rewrites() { - return [ - { - source: "/ingest/static/:path*", - destination: "https://us-assets.i.posthog.com/static/:path*", - }, - { - source: "/ingest/:path*", - destination: "https://us.i.posthog.com/:path*", - }, - { - source: "/ingest/decide", - destination: "https://us.i.posthog.com/decide", - }, - ]; - }, - skipTrailingSlashRedirect: true, + poweredByHeader: false, + reactStrictMode: true, + turbopack: { + resolveAlias: { + underscore: "lodash", + }, + }, + images: { + remotePatterns: [ + { + protocol: "https", + hostname: "**", + }, + ], + }, + transpilePackages: ["@comp/ui"], + logging: { + fetches: { + fullUrl: process.env.LOG_FETCHES === "true", + }, + }, + experimental: { + serverActions: { + bodySizeLimit: "15mb", + }, + nodeMiddleware: true, + authInterrupts: true, + }, + async rewrites() { + return [ + { + source: "/ingest/static/:path*", + destination: "https://us-assets.i.posthog.com/static/:path*", + }, + { + source: "/ingest/:path*", + destination: "https://us.i.posthog.com/:path*", + }, + { + source: "/ingest/decide", + destination: "https://us.i.posthog.com/decide", + }, + ]; + }, + skipTrailingSlashRedirect: true, }; export default config; diff --git a/apps/app/package.json b/apps/app/package.json index 85252aea85..35cb94ce6b 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -1,19 +1,6 @@ { "name": "@comp/app", "version": "0.1.0", - "private": true, - "scripts": { - "dev": "bun i && bun run apply-migrations && bunx concurrently --kill-others --names \"next,trigger\" --prefix-colors \"yellow,blue\" \"next dev --turbo -p 3000\" \"bun run trigger:dev\"", - "trigger:dev": "npx trigger.dev@latest dev", - "build": "next build --turbopack", - "start": "next start", - "lint": "prettier --check .", - "apply-migrations": "cd ../../packages/db && bunx prisma migrate dev && cd ../../apps/app", - "clean-react": "rm -rf node_modules/react; rm -rf node_modules/react-dom", - "deploy:trigger-prod": "npx trigger.dev@latest deploy", - "analyze-locale-usage": "bunx tsx src/locales/analyze-locale-usage.ts", - "typecheck": "tsc --noEmit" - }, "dependencies": { "@ai-sdk/groq": "^1.2.8", "@ai-sdk/openai": "^1.3.19", @@ -22,6 +9,7 @@ "@aws-sdk/client-s3": "^3.806.0", "@aws-sdk/client-sts": "^3.808.0", "@aws-sdk/s3-request-presigner": "^3.806.0", + "@azure/core-rest-pipeline": "^1.21.0", "@browserbasehq/sdk": "^2.5.0", "@calcom/atoms": "^1.0.102-framer", "@calcom/embed-react": "^1.5.3", @@ -32,7 +20,7 @@ "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@hookform/resolvers": "^3.10.0", + "@hookform/resolvers": "^3.9.1", "@mendable/firecrawl-js": "^1.24.0", "@nangohq/frontend": "^0.53.2", "@next/third-parties": "^15.3.1", @@ -42,7 +30,7 @@ "@prisma/instrumentation": "6.6.0", "@react-email/components": "^0.0.41", "@react-email/render": "^1.1.2", - "@tailwindcss/postcss": "^4.1.8", + "@tailwindcss/postcss": "^4.1.10", "@tanstack/react-query": "^5.74.4", "@tanstack/react-table": "^8.21.3", "@tiptap/extension-table": "^2.11.7", @@ -54,31 +42,27 @@ "@tiptap/starter-kit": "^2.11.7", "@trigger.dev/react-hooks": "3.3.17", "@trigger.dev/sdk": "3.3.17", - "@types/d3": "^7.4.3", "@uploadthing/react": "^7.3.0", "@upstash/ratelimit": "^2.0.5", "@vercel/sdk": "^1.7.1", - "ai": "^4.3.10", + "ai": "^4.3.16", "argon2": "^0.43.0", "axios": "^1.9.0", - "better-auth": "^1.2.7", - "bun": "^1.2.10", - "crypto": "^1.0.1", + "better-auth": "^1.2.8", "d3": "^7.9.0", "dub": "^0.46.29", "framer-motion": "^12.9.2", "geist": "^1.3.1", "highlight.js": "^11.11.1", "immer": "^10.1.1", - "install": "^0.13.0", "languine": "^3.1.4", "marked": "^15.0.11", "motion": "^12.9.2", - "next": "^15.4.0-canary.62", + "next": "^15.4.0-canary.83", "next-international": "^1.3.1", "next-intl": "^3.26.5", "next-safe-action": "^7.10.6", - "next-themes": "^0.4.6", + "next-themes": "^0.4.4", "novel": "^1.0.2", "novu": "^2.6.6", "nuqs": "^2.4.3", @@ -86,8 +70,8 @@ "posthog-js": "^1.236.6", "posthog-node": "^4.14.0", "puppeteer-core": "^24.7.2", - "react-dom": "^19.1.0", "react-email": "^4.0.15", + "react-hook-form": "^7.57.0", "react-hotkeys-hook": "^4.6.2", "react-intersection-observer": "^9.16.0", "react-markdown": "^9.1.0", @@ -97,7 +81,7 @@ "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "resend": "^4.4.1", - "sonner": "^1.7.4", + "sonner": "^1.7.1", "stripe": "^18.1.0", "tiptap-markdown": "^0.8.10", "ts-pattern": "^5.7.0", @@ -107,31 +91,40 @@ "zustand": "^5.0.3" }, "devDependencies": { - "next": "15.4.0-canary.62", - "@comp/db": "workspace:*", "@trigger.dev/build": "3.3.17", - "@types/node": "^22.15.2", - "@types/react": "19.1.2", - "@types/react-dom": "19.1.1", + "@types/d3": "^7.4.3", + "@types/node": "^22.13.2", + "eslint": "^9", + "eslint-config-next": "^15.4.0-canary.83", + "fleetctl": "^4.68.1", "postcss": "^8.5.4", + "react": "^19.1.0", + "react-dom": "^19.1.0", "tailwindcss": "^4.1.8", "typescript": "^5.8.3" }, - "resolutions": { - "@types/react": "19.1.2", - "@types/react-dom": "19.1.1" + "exports": { + "./src/lib/encryption": "./src/lib/encryption.ts" + }, + "peerDependencies": { + "react": "^19.1.0", + "react-dom": "^19.1.0" }, "pnpm": { "overrides": { "tiptap-extension-global-drag-handle": "^0.1.18" } }, - "exports": { - "./src/lib/encryption": "./src/lib/encryption.ts" - }, - "peerDependencies": { - "react": "^19", - "react-dom": "^19", - "react-hook-form": "^7.56.3" + "private": true, + "scripts": { + "analyze-locale-usage": "bunx tsx src/locales/analyze-locale-usage.ts", + "apply-migrations": "cd ../../packages/db && bunx prisma migrate dev && cd ../../apps/app", + "build": "next build --turbopack", + "deploy:trigger-prod": "npx trigger.dev@latest deploy", + "dev": "bun i && bun run apply-migrations && bunx concurrently --kill-others --names \"next,trigger\" --prefix-colors \"yellow,blue\" \"next dev --turbo -p 3000\" \"bun run trigger:dev\"", + "lint": "next lint && prettier --check .", + "start": "next start", + "trigger:dev": "npx trigger.dev@latest dev", + "typecheck": "tsc --noEmit" } } diff --git a/apps/app/postcss.config.mjs b/apps/app/postcss.config.mjs index 61e36849cf..df58e971c2 100644 --- a/apps/app/postcss.config.mjs +++ b/apps/app/postcss.config.mjs @@ -1,7 +1,8 @@ const config = { plugins: { - "@tailwindcss/postcss": {}, + "@tailwindcss/postcss": { + config: "../../packages/ui/tailwind.config.ts", + }, }, }; - export default config; diff --git a/apps/app/public/site.webmanifest b/apps/app/public/site.webmanifest index 351bbacff9..9152c16ed7 100644 --- a/apps/app/public/site.webmanifest +++ b/apps/app/public/site.webmanifest @@ -18,4 +18,4 @@ "theme_color": "#ffffff", "background_color": "#ffffff", "display": "standalone" -} \ No newline at end of file +} diff --git a/apps/app/src/actions/add-comment.ts b/apps/app/src/actions/add-comment.ts index 554ff3f23d..fdaa3b384f 100644 --- a/apps/app/src/actions/add-comment.ts +++ b/apps/app/src/actions/add-comment.ts @@ -9,73 +9,68 @@ import { authActionClient } from "./safe-action"; import { CommentEntityType } from "@comp/db/types"; export const addCommentAction = authActionClient - .schema( - z.object({ - content: z.string(), - entityId: z.string(), - entityType: z.nativeEnum(CommentEntityType), - }), - ) - .metadata({ - name: "add-comment", - track: { - event: "add-comment", - channel: "server", - }, - }) - .action(async ({ parsedInput, ctx }) => { - const { content, entityId, entityType } = parsedInput; - const { user, session } = ctx; + .schema( + z.object({ + content: z.string(), + entityId: z.string(), + entityType: z.nativeEnum(CommentEntityType), + }), + ) + .metadata({ + name: "add-comment", + track: { + event: "add-comment", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { content, entityId, entityType } = parsedInput; + const { user, session } = ctx; - if (!session || !session.activeOrganizationId) { - return { - success: false, - error: appErrors.UNAUTHORIZED, - }; - } + if (!session || !session.activeOrganizationId) { + return { + success: false, + error: appErrors.UNAUTHORIZED, + }; + } - try { - const member = await db.member.findFirst({ - where: { - userId: session.userId, - organizationId: session.activeOrganizationId, - }, - }); + try { + const member = await db.member.findFirst({ + where: { + userId: session.userId, + organizationId: session.activeOrganizationId, + }, + }); - if (!member) { - return { - success: false, - error: appErrors.UNAUTHORIZED, - }; - } + if (!member) { + return { + success: false, + error: appErrors.UNAUTHORIZED, + }; + } - const comment = await db.comment.create({ - data: { - content, - entityId, - entityType, - organizationId: session.activeOrganizationId, - authorId: member.id, - }, - }); + const comment = await db.comment.create({ + data: { + content, + entityId, + entityType, + organizationId: session.activeOrganizationId, + authorId: member.id, + }, + }); - const headersList = await headers(); - let path = - headersList.get("x-pathname") || - headersList.get("referer") || - ""; - path = path.replace(/\/[a-z]{2}\//, "/"); + const headersList = await headers(); + let path = + headersList.get("x-pathname") || headersList.get("referer") || ""; + path = path.replace(/\/[a-z]{2}\//, "/"); - revalidatePath(path); + revalidatePath(path); - return { success: true, data: comment }; - } catch (error) { - return { - success: false, - error: - error instanceof AppError - ? error - : appErrors.UNEXPECTED_ERROR, - }; - } - }); + return { success: true, data: comment }; + } catch (error) { + return { + success: false, + error: error instanceof AppError ? error : appErrors.UNEXPECTED_ERROR, + }; + } + }); diff --git a/apps/app/src/actions/change-organization.ts b/apps/app/src/actions/change-organization.ts index ab222ce48c..7f34eac6e2 100644 --- a/apps/app/src/actions/change-organization.ts +++ b/apps/app/src/actions/change-organization.ts @@ -8,69 +8,69 @@ import { z } from "zod"; import { authActionClient } from "./safe-action"; export const changeOrganizationAction = authActionClient - .schema( - z.object({ - organizationId: z.string(), - }), - ) - .metadata({ - name: "change-organization", - track: { - event: "create-employee", - channel: "server", - }, - }) - .action(async ({ parsedInput, ctx }) => { - const { organizationId } = parsedInput; - const { user } = ctx; + .schema( + z.object({ + organizationId: z.string(), + }), + ) + .metadata({ + name: "change-organization", + track: { + event: "create-employee", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { organizationId } = parsedInput; + const { user } = ctx; - const organizationMember = await db.member.findFirst({ - where: { - userId: user.id, - organizationId, - }, - }); + const organizationMember = await db.member.findFirst({ + where: { + userId: user.id, + organizationId, + }, + }); - if (!organizationMember) { - return { - success: false, - error: "Unauthorized", - }; - } + if (!organizationMember) { + return { + success: false, + error: "Unauthorized", + }; + } - try { - const organization = await db.organization.findUnique({ - where: { - id: organizationId, - }, - }); + try { + const organization = await db.organization.findUnique({ + where: { + id: organizationId, + }, + }); - if (!organization) { - return { - success: false, - error: "Organization not found", - }; - } + if (!organization) { + return { + success: false, + error: "Organization not found", + }; + } - auth.api.setActiveOrganization({ - headers: await headers(), - body: { - organizationId: organization.id, - }, - }); + auth.api.setActiveOrganization({ + headers: await headers(), + body: { + organizationId: organization.id, + }, + }); - revalidatePath(`/${organization.id}`); + revalidatePath(`/${organization.id}`); - return { - success: true, - data: organization, - }; - } catch (error) { - console.error("Error changing organization:", error); + return { + success: true, + data: organization, + }; + } catch (error) { + console.error("Error changing organization:", error); - return { - success: false, - error: "Failed to change organization", - }; - } - }); + return { + success: false, + error: "Failed to change organization", + }; + } + }); diff --git a/apps/app/src/actions/comments/createComment.ts b/apps/app/src/actions/comments/createComment.ts index 2ee07c742f..c54ace8bb9 100644 --- a/apps/app/src/actions/comments/createComment.ts +++ b/apps/app/src/actions/comments/createComment.ts @@ -9,130 +9,130 @@ import { z } from "zod"; // Define the input schema const createCommentSchema = z - .object({ - content: z.string(), - entityId: z.string(), - entityType: z.nativeEnum(CommentEntityType), - attachmentIds: z.array(z.string()).optional(), - pathToRevalidate: z.string().optional(), - }) - .refine( - (data) => - // Check if content is non-empty after trimming OR if attachments exist - (data.content && data.content.trim().length > 0) || - (data.attachmentIds && data.attachmentIds.length > 0), - { - message: "Comment cannot be empty unless attachments are provided.", - path: ["content"], - }, - ); + .object({ + content: z.string(), + entityId: z.string(), + entityType: z.nativeEnum(CommentEntityType), + attachmentIds: z.array(z.string()).optional(), + pathToRevalidate: z.string().optional(), + }) + .refine( + (data) => + // Check if content is non-empty after trimming OR if attachments exist + (data.content && data.content.trim().length > 0) || + (data.attachmentIds && data.attachmentIds.length > 0), + { + message: "Comment cannot be empty unless attachments are provided.", + path: ["content"], + }, + ); export const createComment = async ( - input: z.infer, + input: z.infer, ) => { - const { content, entityId, entityType, attachmentIds, pathToRevalidate } = - input; - const session = await auth.api.getSession({ - headers: await headers(), - }); - const orgId = session?.session?.activeOrganizationId; + const { content, entityId, entityType, attachmentIds, pathToRevalidate } = + input; + const session = await auth.api.getSession({ + headers: await headers(), + }); + const orgId = session?.session?.activeOrganizationId; - if (!orgId) { - return { - success: false, - error: "Not authorized - no active organization found.", - data: null, - }; - } + if (!orgId) { + return { + success: false, + error: "Not authorized - no active organization found.", + data: null, + }; + } - if (!entityId) { - console.error("Entity ID missing after validation in createComment"); - return { - success: false, - error: "Internal error: Entity ID missing.", - data: null, - }; - } + if (!entityId) { + console.error("Entity ID missing after validation in createComment"); + return { + success: false, + error: "Internal error: Entity ID missing.", + data: null, + }; + } - try { - // Find the Member ID associated with the user and organization - const member = await db.member.findFirst({ - where: { - userId: session?.user?.id, - organizationId: orgId, - }, - select: { id: true }, - }); + try { + // Find the Member ID associated with the user and organization + const member = await db.member.findFirst({ + where: { + userId: session?.user?.id, + organizationId: orgId, + }, + select: { id: true }, + }); - if (!member) { - return { - success: false, - error: "Not authorized - member not found in organization.", - data: null, - }; - } + if (!member) { + return { + success: false, + error: "Not authorized - member not found in organization.", + data: null, + }; + } - // Wrap create and update in a transaction - const result = await db.$transaction(async (tx) => { - // 1. Create the comment within the transaction - console.log("Creating comment:", { - content, - entityId, - entityType, - memberId: member.id, - organizationId: orgId, - }); - const comment = await tx.comment.create({ - data: { - content: content ?? "", - entityId, - entityType, - authorId: member.id, - organizationId: orgId, - }, - }); + // Wrap create and update in a transaction + const result = await db.$transaction(async (tx) => { + // 1. Create the comment within the transaction + console.log("Creating comment:", { + content, + entityId, + entityType, + memberId: member.id, + organizationId: orgId, + }); + const comment = await tx.comment.create({ + data: { + content: content ?? "", + entityId, + entityType, + authorId: member.id, + organizationId: orgId, + }, + }); - // 2. Link attachments if provided (using updateMany) - if (attachmentIds && attachmentIds.length > 0) { - console.log("Linking attachments to comment:", attachmentIds); - await tx.attachment.updateMany({ - where: { - id: { in: attachmentIds }, - organizationId: orgId, - entityId, - entityType: entityType as AttachmentEntityType, - }, - data: { - entityId: comment.id, - entityType: AttachmentEntityType.comment, - }, - }); - } + // 2. Link attachments if provided (using updateMany) + if (attachmentIds && attachmentIds.length > 0) { + console.log("Linking attachments to comment:", attachmentIds); + await tx.attachment.updateMany({ + where: { + id: { in: attachmentIds }, + organizationId: orgId, + entityId, + entityType: entityType as AttachmentEntityType, + }, + data: { + entityId: comment.id, + entityType: AttachmentEntityType.comment, + }, + }); + } - return comment; - }); + return comment; + }); - const headersList = await headers(); - let path = - headersList.get("x-pathname") || headersList.get("referer") || ""; - path = path.replace(/\/[a-z]{2}\//, "/"); + const headersList = await headers(); + let path = + headersList.get("x-pathname") || headersList.get("referer") || ""; + path = path.replace(/\/[a-z]{2}\//, "/"); - revalidatePath(path); + revalidatePath(path); - return { - success: true, - data: result, - error: null, - }; - } catch (error) { - console.error( - "Failed to create comment with attachments transaction:", - error, - ); - return { - success: false, - error: "Failed to save comment and link attachments.", // More specific error - data: null, - }; - } + return { + success: true, + data: result, + error: null, + }; + } catch (error) { + console.error( + "Failed to create comment with attachments transaction:", + error, + ); + return { + success: false, + error: "Failed to save comment and link attachments.", // More specific error + data: null, + }; + } }; diff --git a/apps/app/src/actions/comments/deleteComment.ts b/apps/app/src/actions/comments/deleteComment.ts index 355c9291e9..5951c201b0 100644 --- a/apps/app/src/actions/comments/deleteComment.ts +++ b/apps/app/src/actions/comments/deleteComment.ts @@ -9,109 +9,107 @@ import { headers } from "next/headers"; import { s3Client, extractS3KeyFromUrl } from "@/app/s3"; const schema = z.object({ - commentId: z.string(), + commentId: z.string(), }); export const deleteComment = async (input: z.infer) => { - const { commentId } = input; - const session = await auth.api.getSession({ - headers: await headers(), - }); - const organizationId = session?.session?.activeOrganizationId; - const userId = session?.session.userId; + const { commentId } = input; + const session = await auth.api.getSession({ + headers: await headers(), + }); + const organizationId = session?.session?.activeOrganizationId; + const userId = session?.session.userId; - if (!organizationId) { - return { - success: false, - error: "Not authorized: No active organization", - }; - } + if (!organizationId) { + return { + success: false, + error: "Not authorized: No active organization", + }; + } - try { - // 1. Fetch the comment, its author, and its attachments - const comment = await db.comment.findUnique({ - where: { id: commentId, organizationId }, - select: { - id: true, - authorId: true, - entityId: true, // Parent task ID for revalidation - attachments: { - // Get attachments to delete from S3 - select: { id: true, url: true }, - }, - }, - }); + try { + // 1. Fetch the comment, its author, and its attachments + const comment = await db.comment.findUnique({ + where: { id: commentId, organizationId }, + select: { + id: true, + authorId: true, + entityId: true, // Parent task ID for revalidation + attachments: { + // Get attachments to delete from S3 + select: { id: true, url: true }, + }, + }, + }); - if (!comment) { - return { - success: false, - error: "Comment not found or access denied", - }; - } + if (!comment) { + return { + success: false, + error: "Comment not found or access denied", + }; + } - // 2. Authorization Check (Placeholder - implement proper logic) - const currentMember = await db.member.findFirst({ - where: { userId, organizationId }, - select: { id: true }, - }); - if (!currentMember || comment.authorId !== currentMember.id) { - // TODO: Add role-based check for admins - return { - success: false, - error: "Not authorized to delete this comment", - }; - } + // 2. Authorization Check (Placeholder - implement proper logic) + const currentMember = await db.member.findFirst({ + where: { userId, organizationId }, + select: { id: true }, + }); + if (!currentMember || comment.authorId !== currentMember.id) { + // TODO: Add role-based check for admins + return { + success: false, + error: "Not authorized to delete this comment", + }; + } - const parentTaskId = comment.entityId; // Store before transaction + const parentTaskId = comment.entityId; // Store before transaction - // --- Start Transaction --- - await db.$transaction(async (tx) => { - // 3. Delete Attachments from S3 (best effort) - if (comment.attachments && comment.attachments.length > 0) { - for (const att of comment.attachments as { - id: string; - url: string; - }[]) { - try { - const key = extractS3KeyFromUrl(att.url); - await s3Client.send( - new DeleteObjectCommand({ - Bucket: process.env.AWS_BUCKET_NAME!, - Key: key, - }), - ); - } catch (s3Error: unknown) { - console.error( - `Failed to delete attachment ${att.id} from S3 during comment deletion:`, - s3Error, - ); - // Continue even if S3 delete fails - } - } - // 4. Delete Attachment records from DB - await tx.attachment.deleteMany({ - where: { entityId: commentId, organizationId }, // Delete all linked to this comment - }); - } + // --- Start Transaction --- + await db.$transaction(async (tx) => { + // 3. Delete Attachments from S3 (best effort) + if (comment.attachments && comment.attachments.length > 0) { + for (const att of comment.attachments as { + id: string; + url: string; + }[]) { + try { + const key = extractS3KeyFromUrl(att.url); + await s3Client.send( + new DeleteObjectCommand({ + Bucket: process.env.AWS_BUCKET_NAME!, + Key: key, + }), + ); + } catch (s3Error: unknown) { + console.error( + `Failed to delete attachment ${att.id} from S3 during comment deletion:`, + s3Error, + ); + // Continue even if S3 delete fails + } + } + // 4. Delete Attachment records from DB + await tx.attachment.deleteMany({ + where: { entityId: commentId, organizationId }, // Delete all linked to this comment + }); + } - // 5. Delete the Comment itself - await tx.comment.delete({ - where: { id: commentId, organizationId }, - }); - }); // --- End Transaction --- + // 5. Delete the Comment itself + await tx.comment.delete({ + where: { id: commentId, organizationId }, + }); + }); // --- End Transaction --- - // Revalidate Task path - if (parentTaskId) { - revalidatePath(`/${organizationId}/tasks/${parentTaskId}`); - } + // Revalidate Task path + if (parentTaskId) { + revalidatePath(`/${organizationId}/tasks/${parentTaskId}`); + } - return { success: true, data: { deletedCommentId: commentId } }; - } catch (error: unknown) { - console.error("Failed to delete comment:", error); - const errorMessage = - error instanceof Error - ? error.message - : "Could not delete comment."; - return { success: false, error: errorMessage }; - } + return { success: true, data: { deletedCommentId: commentId } }; + } catch (error: unknown) { + console.error("Failed to delete comment:", error); + const errorMessage = + error instanceof Error ? error.message : "Could not delete comment."; + return { success: false, error: errorMessage }; + } }; diff --git a/apps/app/src/actions/comments/deleteCommentAttachment.ts b/apps/app/src/actions/comments/deleteCommentAttachment.ts index daa031d72c..87ae01a7bd 100644 --- a/apps/app/src/actions/comments/deleteCommentAttachment.ts +++ b/apps/app/src/actions/comments/deleteCommentAttachment.ts @@ -9,145 +9,135 @@ import { z } from "zod"; import { s3Client, BUCKET_NAME, extractS3KeyFromUrl } from "@/app/s3"; const schema = z.object({ - attachmentId: z.string(), + attachmentId: z.string(), }); export const deleteCommentAttachment = authActionClient - .schema(schema) - .metadata({ - name: "deleteCommentAttachment", - track: { event: "delete-comment-attachment", channel: "server" }, - }) - .action(async ({ parsedInput, ctx }) => { - const { session, user } = ctx; - const { attachmentId } = parsedInput; - const organizationId = session.activeOrganizationId; - const userId = user.id; + .schema(schema) + .metadata({ + name: "deleteCommentAttachment", + track: { event: "delete-comment-attachment", channel: "server" }, + }) + .action(async ({ parsedInput, ctx }) => { + const { session, user } = ctx; + const { attachmentId } = parsedInput; + const organizationId = session.activeOrganizationId; + const userId = user.id; - if (!organizationId) { - return { success: false, error: "Not authorized" } as const; - } + if (!organizationId) { + return { success: false, error: "Not authorized" } as const; + } - try { - // 1. Find the attachment and verify ownership/TYPE - const attachmentToDelete = await db.attachment.findUnique({ - where: { - id: attachmentId, - organizationId: organizationId, - // No include needed here - }, - }); + try { + // 1. Find the attachment and verify ownership/TYPE + const attachmentToDelete = await db.attachment.findUnique({ + where: { + id: attachmentId, + organizationId: organizationId, + // No include needed here + }, + }); - if (!attachmentToDelete) { - return { - success: false, - error: "Attachment not found or access denied", - } as const; - } + if (!attachmentToDelete) { + return { + success: false, + error: "Attachment not found or access denied", + } as const; + } - // 1b. Verify it's a comment attachment - if ( - attachmentToDelete.entityType !== AttachmentEntityType.comment - ) { - console.error( - "Attachment requested for deletion is not a comment attachment", - attachmentId, - ); - return { - success: false, - error: "Invalid attachment type for deletion", - } as const; - } + // 1b. Verify it's a comment attachment + if (attachmentToDelete.entityType !== AttachmentEntityType.comment) { + console.error( + "Attachment requested for deletion is not a comment attachment", + attachmentId, + ); + return { + success: false, + error: "Invalid attachment type for deletion", + } as const; + } - // 2. Fetch the associated Comment for authorization check and revalidation path - const comment = await db.comment.findUnique({ - where: { - id: attachmentToDelete.entityId, - organizationId: organizationId, // Ensure comment is in the same org - }, - select: { - authorId: true, // Need author to check permission - entityId: true, // Need parent task ID for revalidation - }, - }); + // 2. Fetch the associated Comment for authorization check and revalidation path + const comment = await db.comment.findUnique({ + where: { + id: attachmentToDelete.entityId, + organizationId: organizationId, // Ensure comment is in the same org + }, + select: { + authorId: true, // Need author to check permission + entityId: true, // Need parent task ID for revalidation + }, + }); - if (!comment) { - console.error( - "Comment associated with attachment not found during delete", - attachmentId, - attachmentToDelete.entityId, - ); - // Proceed with deleting the attachment record, but log the error. - // S3 deletion might still proceed if key extraction works. - } + if (!comment) { + console.error( + "Comment associated with attachment not found during delete", + attachmentId, + attachmentToDelete.entityId, + ); + // Proceed with deleting the attachment record, but log the error. + // S3 deletion might still proceed if key extraction works. + } - // 3. Authorization Check: Ensure user is the comment author - const authorMember = await db.member.findFirst({ - where: { - userId: userId, - organizationId: organizationId, - }, - select: { id: true }, - }); + // 3. Authorization Check: Ensure user is the comment author + const authorMember = await db.member.findFirst({ + where: { + userId: userId, + organizationId: organizationId, + }, + select: { id: true }, + }); - // Check if comment was found AND if the author matches - if ( - !authorMember || - !comment || - comment.authorId !== authorMember.id - ) { - // Add role-based check here if admins should also be able to delete - return { - success: false, - error: "Not authorized to delete this attachment", - } as const; - } + // Check if comment was found AND if the author matches + if (!authorMember || !comment || comment.authorId !== authorMember.id) { + // Add role-based check here if admins should also be able to delete + return { + success: false, + error: "Not authorized to delete this attachment", + } as const; + } - // 4. Attempt to delete from S3 using shared client - let key: string; - try { - key = extractS3KeyFromUrl(attachmentToDelete.url); - const deleteCommand = new DeleteObjectCommand({ - Bucket: BUCKET_NAME!, - Key: key, - }); - await s3Client.send(deleteCommand); - } catch (s3Error: any) { - // Log error but proceed to delete DB record (orphan file is better than inconsistent state) - console.error( - "S3 Delete Error for comment attachment:", - attachmentId, - s3Error, - ); - } + // 4. Attempt to delete from S3 using shared client + let key: string; + try { + key = extractS3KeyFromUrl(attachmentToDelete.url); + const deleteCommand = new DeleteObjectCommand({ + Bucket: BUCKET_NAME!, + Key: key, + }); + await s3Client.send(deleteCommand); + } catch (s3Error: any) { + // Log error but proceed to delete DB record (orphan file is better than inconsistent state) + console.error( + "S3 Delete Error for comment attachment:", + attachmentId, + s3Error, + ); + } - // 5. Delete Attachment record from Database - await db.attachment.delete({ - where: { - id: attachmentId, - organizationId: organizationId, - }, - }); + // 5. Delete Attachment record from Database + await db.attachment.delete({ + where: { + id: attachmentId, + organizationId: organizationId, + }, + }); - // 6. Revalidate the parent task path (using comment fetched earlier) - if (comment?.entityId) { - // Check if comment was found before revalidating - revalidatePath(`/${organizationId}/tasks/${comment.entityId}`); - } + // 6. Revalidate the parent task path (using comment fetched earlier) + if (comment?.entityId) { + // Check if comment was found before revalidating + revalidatePath(`/${organizationId}/tasks/${comment.entityId}`); + } - return { - success: true, - data: { deletedAttachmentId: attachmentId }, - }; - } catch (error: any) { - console.error( - "Error deleting comment attachment:", - attachmentId, - error, - ); - return { - success: false, - error: "Failed to delete attachment.", - } as const; - } - }); + return { + success: true, + data: { deletedAttachmentId: attachmentId }, + }; + } catch (error: any) { + console.error("Error deleting comment attachment:", attachmentId, error); + return { + success: false, + error: "Failed to delete attachment.", + } as const; + } + }); diff --git a/apps/app/src/actions/comments/getCommentAttachmentUrl.ts b/apps/app/src/actions/comments/getCommentAttachmentUrl.ts index 061ee69b56..9f594bbca3 100644 --- a/apps/app/src/actions/comments/getCommentAttachmentUrl.ts +++ b/apps/app/src/actions/comments/getCommentAttachmentUrl.ts @@ -10,121 +10,121 @@ import { auth } from "@/utils/auth"; import { headers } from "next/headers"; const schema = z.object({ - attachmentId: z.string(), + attachmentId: z.string(), }); export const getCommentAttachmentUrl = async ( - input: z.infer, + input: z.infer, ) => { - const session = await auth.api.getSession({ - headers: await headers(), - }); - const { attachmentId } = input; - const organizationId = session?.session?.activeOrganizationId; + const session = await auth.api.getSession({ + headers: await headers(), + }); + const { attachmentId } = input; + const organizationId = session?.session?.activeOrganizationId; - if (!organizationId) { - return { - success: false, - error: "Not authorized - no organization found", - } as const; - } + if (!organizationId) { + return { + success: false, + error: "Not authorized - no organization found", + } as const; + } - try { - // 1. Find the attachment and verify ownership & TYPE - const attachment = await db.attachment.findUnique({ - where: { - id: attachmentId, - organizationId: organizationId, - }, - }); + try { + // 1. Find the attachment and verify ownership & TYPE + const attachment = await db.attachment.findUnique({ + where: { + id: attachmentId, + organizationId: organizationId, + }, + }); - if (!attachment) { - return { - success: false, - error: "Attachment not found or access denied", - } as const; - } + if (!attachment) { + return { + success: false, + error: "Attachment not found or access denied", + } as const; + } - // 1b. Check if it's actually a comment attachment - if (attachment.entityType !== AttachmentEntityType.comment) { - console.error( - "Attachment requested is not a comment attachment", - attachmentId, - ); - return { - success: false, - error: "Invalid attachment type requested", - } as const; - } + // 1b. Check if it's actually a comment attachment + if (attachment.entityType !== AttachmentEntityType.comment) { + console.error( + "Attachment requested is not a comment attachment", + attachmentId, + ); + return { + success: false, + error: "Invalid attachment type requested", + } as const; + } - // 2. Fetch the associated Comment - const comment = await db.comment.findUnique({ - where: { - id: attachment.entityId, // Use entityId from attachment - organizationId: organizationId, // Ensure comment is in the same org - }, - }); + // 2. Fetch the associated Comment + const comment = await db.comment.findUnique({ + where: { + id: attachment.entityId, // Use entityId from attachment + organizationId: organizationId, // Ensure comment is in the same org + }, + }); - if (!comment) { - console.error( - "Comment associated with attachment not found", - attachmentId, - attachment.entityId, - ); - return { - success: false, - error: "Attachment link error (Comment not found)", - } as const; - } + if (!comment) { + console.error( + "Comment associated with attachment not found", + attachmentId, + attachment.entityId, + ); + return { + success: false, + error: "Attachment link error (Comment not found)", + } as const; + } - // 3. Extract S3 key - let key: string; - try { - key = extractS3KeyFromUrl(attachment.url); - } catch (extractError) { - console.error( - "Error extracting S3 key for comment attachment:", - attachmentId, - extractError, - ); - return { - success: false, - error: "Could not process attachment URL", - } as const; - } + // 3. Extract S3 key + let key: string; + try { + key = extractS3KeyFromUrl(attachment.url); + } catch (extractError) { + console.error( + "Error extracting S3 key for comment attachment:", + attachmentId, + extractError, + ); + return { + success: false, + error: "Could not process attachment URL", + } as const; + } - // 4. Generate Signed URL using shared client and bucket name - try { - const command = new GetObjectCommand({ - Bucket: BUCKET_NAME!, - Key: key, - }); + // 4. Generate Signed URL using shared client and bucket name + try { + const command = new GetObjectCommand({ + Bucket: BUCKET_NAME!, + Key: key, + }); - const signedUrl = await getSignedUrl(s3Client, command, { - expiresIn: 3600, - }); + const signedUrl = await getSignedUrl(s3Client, command, { + expiresIn: 3600, + }); - if (!signedUrl) { - console.error("getSignedUrl returned undefined for key:", key); - return { - success: false, - error: "Failed to generate signed URL", - } as const; - } + if (!signedUrl) { + console.error("getSignedUrl returned undefined for key:", key); + return { + success: false, + error: "Failed to generate signed URL", + } as const; + } - return { success: true, data: { signedUrl } }; - } catch (s3Error) { - console.error("S3 getSignedUrl Error:", s3Error); - return { - success: false, - error: "Could not generate access URL for the file", - } as const; - } - } catch (dbError) { - console.error("Database Error fetching comment attachment:", dbError); - return { - success: false, - error: "Failed to retrieve attachment details", - } as const; - } + return { success: true, data: { signedUrl } }; + } catch (s3Error) { + console.error("S3 getSignedUrl Error:", s3Error); + return { + success: false, + error: "Could not generate access URL for the file", + } as const; + } + } catch (dbError) { + console.error("Database Error fetching comment attachment:", dbError); + return { + success: false, + error: "Failed to retrieve attachment details", + } as const; + } }; diff --git a/apps/app/src/actions/comments/updateComment.ts b/apps/app/src/actions/comments/updateComment.ts index 47ec386cc5..e424512de0 100644 --- a/apps/app/src/actions/comments/updateComment.ts +++ b/apps/app/src/actions/comments/updateComment.ts @@ -10,161 +10,157 @@ import { BUCKET_NAME, extractS3KeyFromUrl, s3Client } from "@/app/s3"; import { DeleteObjectCommand } from "@aws-sdk/client-s3"; const schema = z - .object({ - commentId: z.string(), - content: z.string().optional(), // Optional: content might not change - attachmentIdsToAdd: z.array(z.string()).optional(), - attachmentIdsToRemove: z.array(z.string()).optional(), - }) - .refine( - (data) => - data.content !== undefined || - data.attachmentIdsToAdd?.length || - data.attachmentIdsToRemove?.length, - { message: "No changes provided for update." }, - ); + .object({ + commentId: z.string(), + content: z.string().optional(), // Optional: content might not change + attachmentIdsToAdd: z.array(z.string()).optional(), + attachmentIdsToRemove: z.array(z.string()).optional(), + }) + .refine( + (data) => + data.content !== undefined || + data.attachmentIdsToAdd?.length || + data.attachmentIdsToRemove?.length, + { message: "No changes provided for update." }, + ); export const updateComment = async (input: z.infer) => { - const session = await auth.api.getSession({ - headers: await headers(), - }); - const { commentId, content, attachmentIdsToAdd, attachmentIdsToRemove } = - schema.parse(input); - const organizationId = session?.session?.activeOrganizationId; - const userId = session?.session.userId; + const session = await auth.api.getSession({ + headers: await headers(), + }); + const { commentId, content, attachmentIdsToAdd, attachmentIdsToRemove } = + schema.parse(input); + const organizationId = session?.session?.activeOrganizationId; + const userId = session?.session.userId; - if (!organizationId) { - return { - success: false, - error: "Not authorized", - data: null, - }; - } + if (!organizationId) { + return { + success: false, + error: "Not authorized", + data: null, + }; + } - try { - // 1. Fetch comment, include ID for return value consistency - const comment = await db.comment.findUnique({ - where: { id: commentId, organizationId }, - select: { id: true, authorId: true, entityId: true }, - }); + try { + // 1. Fetch comment, include ID for return value consistency + const comment = await db.comment.findUnique({ + where: { id: commentId, organizationId }, + select: { id: true, authorId: true, entityId: true }, + }); - if (!comment) { - return { - success: false, - error: "Comment not found", - data: null, - }; - } + if (!comment) { + return { + success: false, + error: "Comment not found", + data: null, + }; + } - // 2. Authorization Check (Placeholder - implement proper logic) - const currentMember = await db.member.findFirst({ - where: { userId, organizationId }, - select: { id: true }, - }); - if (!currentMember || comment.authorId !== currentMember.id) { - // TODO: Add role-based check for admins - return { - success: false, - error: "Not authorized", - data: null, - }; - } + // 2. Authorization Check (Placeholder - implement proper logic) + const currentMember = await db.member.findFirst({ + where: { userId, organizationId }, + select: { id: true }, + }); + if (!currentMember || comment.authorId !== currentMember.id) { + // TODO: Add role-based check for admins + return { + success: false, + error: "Not authorized", + data: null, + }; + } - // --- Start Transaction --- - const updatedCommentResult = await db.$transaction(async (tx) => { - let updatedCommentData: Comment | null = null; - if (content !== undefined) { - // update returns the full comment object including id - updatedCommentData = await tx.comment.update({ - where: { id: commentId }, - data: { content }, - }); - } + // --- Start Transaction --- + const updatedCommentResult = await db.$transaction(async (tx) => { + let updatedCommentData: Comment | null = null; + if (content !== undefined) { + // update returns the full comment object including id + updatedCommentData = await tx.comment.update({ + where: { id: commentId }, + data: { content }, + }); + } - // 4. Handle Attachments to Remove - if (attachmentIdsToRemove && attachmentIdsToRemove.length > 0) { - const attachments = await tx.attachment.findMany({ - where: { - id: { in: attachmentIdsToRemove }, - organizationId, - entityId: commentId, - }, - select: { id: true, url: true }, - }); + // 4. Handle Attachments to Remove + if (attachmentIdsToRemove && attachmentIdsToRemove.length > 0) { + const attachments = await tx.attachment.findMany({ + where: { + id: { in: attachmentIdsToRemove }, + organizationId, + entityId: commentId, + }, + select: { id: true, url: true }, + }); - // Delete from S3 (best effort) - for (const attachmentRecord of attachments) { - // Ensure type for internal usage - const att: { id: string; url: string } = attachmentRecord; - try { - const key = extractS3KeyFromUrl(att.url); - await s3Client.send( - new DeleteObjectCommand({ - Bucket: BUCKET_NAME, - Key: key, - }), - ); - } catch (s3Error: unknown) { - console.error( - `Failed to delete attachment ${att.id} from S3:`, - s3Error, - ); - // Continue even if S3 delete fails - } - } + // Delete from S3 (best effort) + for (const attachmentRecord of attachments) { + // Ensure type for internal usage + const att: { id: string; url: string } = attachmentRecord; + try { + const key = extractS3KeyFromUrl(att.url); + await s3Client.send( + new DeleteObjectCommand({ + Bucket: BUCKET_NAME, + Key: key, + }), + ); + } catch (s3Error: unknown) { + console.error( + `Failed to delete attachment ${att.id} from S3:`, + s3Error, + ); + // Continue even if S3 delete fails + } + } - // Delete from DB - await tx.attachment.deleteMany({ - where: { - id: { in: attachments.map((a) => a.id) }, - organizationId, - entityId: commentId, - }, - }); - } + // Delete from DB + await tx.attachment.deleteMany({ + where: { + id: { in: attachments.map((a) => a.id) }, + organizationId, + entityId: commentId, + }, + }); + } - // 5. Handle Attachments to Add - if (attachmentIdsToAdd && attachmentIdsToAdd.length > 0) { - // Link attachments that were temporarily uploaded linked to the TASK - await tx.attachment.updateMany({ - where: { - id: { in: attachmentIdsToAdd }, - organizationId, - entityType: AttachmentEntityType.comment, // Ensure they were uploaded for a comment - // IMPORTANT: Assuming upload linked them to the *TASK* ID temporarily - entityId: comment.entityId, // Check they are linked to the parent task - }, - data: { - entityId: commentId, // Link to the actual comment ID - }, - }); - // TODO: Check update count matches length of attachmentIdsToAdd? - } + // 5. Handle Attachments to Add + if (attachmentIdsToAdd && attachmentIdsToAdd.length > 0) { + // Link attachments that were temporarily uploaded linked to the TASK + await tx.attachment.updateMany({ + where: { + id: { in: attachmentIdsToAdd }, + organizationId, + entityType: AttachmentEntityType.comment, // Ensure they were uploaded for a comment + // IMPORTANT: Assuming upload linked them to the *TASK* ID temporarily + entityId: comment.entityId, // Check they are linked to the parent task + }, + data: { + entityId: commentId, // Link to the actual comment ID + }, + }); + // TODO: Check update count matches length of attachmentIdsToAdd? + } - // Return the newly updated comment data or the original fetched comment - return updatedCommentData || comment; - }); // --- End Transaction --- + // Return the newly updated comment data or the original fetched comment + return updatedCommentData || comment; + }); // --- End Transaction --- - revalidatePath( - `/${organizationId}/tasks/${updatedCommentResult.entityId}`, - ); + revalidatePath(`/${organizationId}/tasks/${updatedCommentResult.entityId}`); - return { - success: true, - error: null, - data: updatedCommentResult, - }; - } catch (error) { - // Use unknown for outer catch block - console.error("Failed to update comment:", error); - // Type checking before accessing message - const errorMessage = - error instanceof Error - ? error.message - : "Could not update comment."; - return { - success: false, - error: errorMessage, - }; - } + return { + success: true, + error: null, + data: updatedCommentResult, + }; + } catch (error) { + // Use unknown for outer catch block + console.error("Failed to update comment:", error); + // Type checking before accessing message + const errorMessage = + error instanceof Error ? error.message : "Could not update comment."; + return { + success: false, + error: errorMessage, + }; + } }; diff --git a/apps/app/src/actions/context-hub/create-context-entry-action.ts b/apps/app/src/actions/context-hub/create-context-entry-action.ts index b93dfe563f..99604c97f5 100644 --- a/apps/app/src/actions/context-hub/create-context-entry-action.ts +++ b/apps/app/src/actions/context-hub/create-context-entry-action.ts @@ -7,33 +7,33 @@ import { createContextEntrySchema } from "../schema"; import { db } from "@comp/db"; export const createContextEntryAction = authActionClient - .schema(createContextEntrySchema) - .metadata({ name: "create-context-entry" }) - .action(async ({ parsedInput, ctx }) => { - const { question, answer, tags } = parsedInput; - const organizationId = ctx.session.activeOrganizationId; - if (!organizationId) throw new Error("No active organization"); + .schema(createContextEntrySchema) + .metadata({ name: "create-context-entry" }) + .action(async ({ parsedInput, ctx }) => { + const { question, answer, tags } = parsedInput; + const organizationId = ctx.session.activeOrganizationId; + if (!organizationId) throw new Error("No active organization"); - await db.context.create({ - data: { - question, - answer, - tags: tags - ? tags - .split(",") - .map((t) => t.trim()) - .filter(Boolean) - : [], - organizationId, - }, - }); + await db.context.create({ + data: { + question, + answer, + tags: tags + ? tags + .split(",") + .map((t) => t.trim()) + .filter(Boolean) + : [], + organizationId, + }, + }); - const headersList = await headers(); - let path = - headersList.get("x-pathname") || headersList.get("referer") || ""; - path = path.replace(/\/[a-z]{2}\//, "/"); + const headersList = await headers(); + let path = + headersList.get("x-pathname") || headersList.get("referer") || ""; + path = path.replace(/\/[a-z]{2}\//, "/"); - revalidatePath(path); + revalidatePath(path); - return { success: true }; - }); + return { success: true }; + }); diff --git a/apps/app/src/actions/context-hub/delete-context-entry-action.ts b/apps/app/src/actions/context-hub/delete-context-entry-action.ts index 3dc915d4c0..c693f202d5 100644 --- a/apps/app/src/actions/context-hub/delete-context-entry-action.ts +++ b/apps/app/src/actions/context-hub/delete-context-entry-action.ts @@ -7,22 +7,22 @@ import { deleteContextEntrySchema } from "../schema"; import { db } from "@comp/db"; export const deleteContextEntryAction = authActionClient - .schema(deleteContextEntrySchema) - .metadata({ name: "delete-context-entry" }) - .action(async ({ parsedInput, ctx }) => { - const { id } = parsedInput; - const organizationId = ctx.session.activeOrganizationId; - if (!organizationId) throw new Error("No active organization"); + .schema(deleteContextEntrySchema) + .metadata({ name: "delete-context-entry" }) + .action(async ({ parsedInput, ctx }) => { + const { id } = parsedInput; + const organizationId = ctx.session.activeOrganizationId; + if (!organizationId) throw new Error("No active organization"); - await db.context.delete({ - where: { id, organizationId }, - }); + await db.context.delete({ + where: { id, organizationId }, + }); - const headersList = await headers(); - let path = - headersList.get("x-pathname") || headersList.get("referer") || ""; - path = path.replace(/\/[a-z]{2}\//, "/"); + const headersList = await headers(); + let path = + headersList.get("x-pathname") || headersList.get("referer") || ""; + path = path.replace(/\/[a-z]{2}\//, "/"); - revalidatePath(path); - return { success: true }; - }); + revalidatePath(path); + return { success: true }; + }); diff --git a/apps/app/src/actions/context-hub/update-context-entry-action.ts b/apps/app/src/actions/context-hub/update-context-entry-action.ts index f19a961122..3a0836711a 100644 --- a/apps/app/src/actions/context-hub/update-context-entry-action.ts +++ b/apps/app/src/actions/context-hub/update-context-entry-action.ts @@ -7,33 +7,33 @@ import { updateContextEntrySchema } from "../schema"; import { db } from "@comp/db"; export const updateContextEntryAction = authActionClient - .schema(updateContextEntrySchema) - .metadata({ name: "update-context-entry" }) - .action(async ({ parsedInput, ctx }) => { - const { id, question, answer, tags } = parsedInput; - const organizationId = ctx.session.activeOrganizationId; - if (!organizationId) throw new Error("No active organization"); + .schema(updateContextEntrySchema) + .metadata({ name: "update-context-entry" }) + .action(async ({ parsedInput, ctx }) => { + const { id, question, answer, tags } = parsedInput; + const organizationId = ctx.session.activeOrganizationId; + if (!organizationId) throw new Error("No active organization"); - await db.context.update({ - where: { id, organizationId }, - data: { - question, - answer, - tags: tags - ? tags - .split(",") - .map((t) => t.trim()) - .filter(Boolean) - : [], - }, - }); + await db.context.update({ + where: { id, organizationId }, + data: { + question, + answer, + tags: tags + ? tags + .split(",") + .map((t) => t.trim()) + .filter(Boolean) + : [], + }, + }); - const headersList = await headers(); - let path = - headersList.get("x-pathname") || headersList.get("referer") || ""; - path = path.replace(/\/[a-z]{2}\//, "/"); + const headersList = await headers(); + let path = + headersList.get("x-pathname") || headersList.get("referer") || ""; + path = path.replace(/\/[a-z]{2}\//, "/"); - revalidatePath(path); + revalidatePath(path); - return { success: true }; - }); + return { success: true }; + }); diff --git a/apps/app/src/actions/files/upload-file.ts b/apps/app/src/actions/files/upload-file.ts index 7bfc754c5e..2439c014e6 100644 --- a/apps/app/src/actions/files/upload-file.ts +++ b/apps/app/src/actions/files/upload-file.ts @@ -11,127 +11,127 @@ import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { revalidatePath } from "next/cache"; function mapFileTypeToAttachmentType(fileType: string): AttachmentType { - const type = fileType.split("/")[0]; - switch (type) { - case "image": - return AttachmentType.image; - case "video": - return AttachmentType.video; - case "audio": - return AttachmentType.audio; - case "application": - return AttachmentType.document; - default: - return AttachmentType.other; - } + const type = fileType.split("/")[0]; + switch (type) { + case "image": + return AttachmentType.image; + case "video": + return AttachmentType.video; + case "audio": + return AttachmentType.audio; + case "application": + return AttachmentType.document; + default: + return AttachmentType.other; + } } const uploadAttachmentSchema = z.object({ - fileName: z.string(), - fileType: z.string(), - fileData: z.string(), - entityId: z.string(), - entityType: z.nativeEnum(AttachmentEntityType), - pathToRevalidate: z.string().optional(), + fileName: z.string(), + fileType: z.string(), + fileData: z.string(), + entityId: z.string(), + entityType: z.nativeEnum(AttachmentEntityType), + pathToRevalidate: z.string().optional(), }); export const uploadFile = async ( - input: z.infer, + input: z.infer, ) => { - const { - fileName, - fileType, - fileData, - entityId, - entityType, - pathToRevalidate, - } = input; - const session = await auth.api.getSession({ headers: await headers() }); - const organizationId = session?.session.activeOrganizationId; - - if (!organizationId) { - return { - success: false, - error: "Not authorized - no organization found", - data: null, - }; - } - - try { - const fileBuffer = Buffer.from(fileData, "base64"); - - const MAX_FILE_SIZE_MB = 5; - const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024; - if (fileBuffer.length > MAX_FILE_SIZE_BYTES) { - return { - success: false, - error: `File exceeds the ${MAX_FILE_SIZE_MB}MB limit.`, - data: null, - }; - } - - const timestamp = Date.now(); - const sanitizedFileName = fileName.replace(/[^a-zA-Z0-9.-]/g, "_"); - const key = `${organizationId}/attachments/${entityType}/${entityId}/${timestamp}-${sanitizedFileName}`; - - const putCommand = new PutObjectCommand({ - Bucket: BUCKET_NAME, - Key: key, - Body: fileBuffer, - ContentType: fileType, - }); - - await s3Client.send(putCommand); - - console.log("Creating attachment..."); - console.log({ - name: fileName, - url: key, - type: mapFileTypeToAttachmentType(fileType), - entityId: entityId, - entityType: entityType, - organizationId: organizationId, - }); - - const attachment = await db.attachment.create({ - data: { - name: fileName, - url: key, - type: mapFileTypeToAttachmentType(fileType), - entityId: entityId, - entityType: entityType, - organizationId: organizationId, - }, - }); - - const getCommand = new GetObjectCommand({ - Bucket: BUCKET_NAME, - Key: key, - }); - - const signedUrl = await getSignedUrl(s3Client, getCommand, { - expiresIn: 900, - }); - - if (pathToRevalidate) { - revalidatePath(pathToRevalidate); - } - - return { - success: true, - data: { - ...attachment, - signedUrl, - }, - error: null, - } as const; - } catch (error) { - console.error("Upload file action error:", error); - - return { - success: false, - error: "Failed to process file upload.", - data: null, - } as const; - } + const { + fileName, + fileType, + fileData, + entityId, + entityType, + pathToRevalidate, + } = input; + const session = await auth.api.getSession({ headers: await headers() }); + const organizationId = session?.session.activeOrganizationId; + + if (!organizationId) { + return { + success: false, + error: "Not authorized - no organization found", + data: null, + }; + } + + try { + const fileBuffer = Buffer.from(fileData, "base64"); + + const MAX_FILE_SIZE_MB = 5; + const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024; + if (fileBuffer.length > MAX_FILE_SIZE_BYTES) { + return { + success: false, + error: `File exceeds the ${MAX_FILE_SIZE_MB}MB limit.`, + data: null, + }; + } + + const timestamp = Date.now(); + const sanitizedFileName = fileName.replace(/[^a-zA-Z0-9.-]/g, "_"); + const key = `${organizationId}/attachments/${entityType}/${entityId}/${timestamp}-${sanitizedFileName}`; + + const putCommand = new PutObjectCommand({ + Bucket: BUCKET_NAME, + Key: key, + Body: fileBuffer, + ContentType: fileType, + }); + + await s3Client.send(putCommand); + + console.log("Creating attachment..."); + console.log({ + name: fileName, + url: key, + type: mapFileTypeToAttachmentType(fileType), + entityId: entityId, + entityType: entityType, + organizationId: organizationId, + }); + + const attachment = await db.attachment.create({ + data: { + name: fileName, + url: key, + type: mapFileTypeToAttachmentType(fileType), + entityId: entityId, + entityType: entityType, + organizationId: organizationId, + }, + }); + + const getCommand = new GetObjectCommand({ + Bucket: BUCKET_NAME, + Key: key, + }); + + const signedUrl = await getSignedUrl(s3Client, getCommand, { + expiresIn: 900, + }); + + if (pathToRevalidate) { + revalidatePath(pathToRevalidate); + } + + return { + success: true, + data: { + ...attachment, + signedUrl, + }, + error: null, + } as const; + } catch (error) { + console.error("Upload file action error:", error); + + return { + success: false, + error: "Failed to process file upload.", + data: null, + } as const; + } }; diff --git a/apps/app/src/actions/floating.ts b/apps/app/src/actions/floating.ts index 4db3bf7613..5091439f69 100644 --- a/apps/app/src/actions/floating.ts +++ b/apps/app/src/actions/floating.ts @@ -6,19 +6,19 @@ import { cookies } from "next/headers"; import { z } from "zod"; const schema = z.object({ - floatingOpen: z.boolean(), + floatingOpen: z.boolean(), }); export const updateFloatingState = createSafeActionClient() - .schema(schema) - .action(async ({ parsedInput }) => { - const cookieStore = await cookies(); + .schema(schema) + .action(async ({ parsedInput }) => { + const cookieStore = await cookies(); - cookieStore.set({ - name: "floating-onboarding-checklist", - value: JSON.stringify(parsedInput.floatingOpen), - expires: addYears(new Date(), 1), - }); + cookieStore.set({ + name: "floating-onboarding-checklist", + value: JSON.stringify(parsedInput.floatingOpen), + expires: addYears(new Date(), 1), + }); - return { success: true }; - }); + return { success: true }; + }); diff --git a/apps/app/src/actions/integrations/delete-integration-connection.ts b/apps/app/src/actions/integrations/delete-integration-connection.ts index 614719aea0..473fde8108 100644 --- a/apps/app/src/actions/integrations/delete-integration-connection.ts +++ b/apps/app/src/actions/integrations/delete-integration-connection.ts @@ -8,48 +8,48 @@ import { authActionClient } from "../safe-action"; import { deleteIntegrationConnectionSchema } from "../schema"; export const deleteIntegrationConnectionAction = authActionClient - .schema(deleteIntegrationConnectionSchema) - .metadata({ - name: "delete-integration-connection", - track: { - event: "delete-integration-connection", - channel: "server", - }, - }) - .action(async ({ parsedInput, ctx }) => { - const { integrationName } = parsedInput; - const { session } = ctx; - - if (!session.activeOrganizationId) { - return { - success: false, - error: "Unauthorized", - }; - } - - const integration = await db.integration.findFirst({ - where: { - name: integrationName, - organizationId: session.activeOrganizationId, - }, - }); - - if (!integration) { - return { - success: false, - error: "Integration not found", - }; - } - - await db.integration.delete({ - where: { - id: integration.id, - }, - }); - - revalidatePath("/integrations"); - - return { - success: true, - }; - }); + .schema(deleteIntegrationConnectionSchema) + .metadata({ + name: "delete-integration-connection", + track: { + event: "delete-integration-connection", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { integrationName } = parsedInput; + const { session } = ctx; + + if (!session.activeOrganizationId) { + return { + success: false, + error: "Unauthorized", + }; + } + + const integration = await db.integration.findFirst({ + where: { + name: integrationName, + organizationId: session.activeOrganizationId, + }, + }); + + if (!integration) { + return { + success: false, + error: "Integration not found", + }; + } + + await db.integration.delete({ + where: { + id: integration.id, + }, + }); + + revalidatePath("/integrations"); + + return { + success: true, + }; + }); diff --git a/apps/app/src/actions/integrations/retrieve-integration-session-token.ts b/apps/app/src/actions/integrations/retrieve-integration-session-token.ts index 213d606a02..f8311bb78c 100644 --- a/apps/app/src/actions/integrations/retrieve-integration-session-token.ts +++ b/apps/app/src/actions/integrations/retrieve-integration-session-token.ts @@ -6,20 +6,20 @@ import { authActionClient } from "../safe-action"; import { createIntegrationSchema } from "../schema"; export const retrieveIntegrationSessionTokenAction = authActionClient - .schema(createIntegrationSchema) - .metadata({ - name: "retrieve-integration-session-token", - track: { - event: "retrieve-integration-session-token", - channel: "server", - }, - }) - .action(async ({ parsedInput, ctx }) => { - const { integrationId } = parsedInput; - const { user } = ctx; + .schema(createIntegrationSchema) + .metadata({ + name: "retrieve-integration-session-token", + track: { + event: "retrieve-integration-session-token", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { integrationId } = parsedInput; + const { user } = ctx; - return { - success: true, - sessionToken: "123", - }; - }); + return { + success: true, + sessionToken: "123", + }; + }); diff --git a/apps/app/src/actions/integrations/update-integration-settings-action.ts b/apps/app/src/actions/integrations/update-integration-settings-action.ts index 96345c2181..0c16978716 100644 --- a/apps/app/src/actions/integrations/update-integration-settings-action.ts +++ b/apps/app/src/actions/integrations/update-integration-settings-action.ts @@ -7,99 +7,94 @@ import { z } from "zod"; import { authActionClient } from "../safe-action"; export const updateIntegrationSettingsAction = authActionClient - .schema( - z.object({ - integration_id: z.string(), - option: z.object({ - id: z.string(), - value: z.unknown(), - }), - }), - ) - .metadata({ - name: "update-integration-settings", - track: { - event: "update-integration-settings", - channel: "update-integration-settings", - }, - }) - .action( - async ({ - parsedInput: { integration_id, option }, - ctx: { session }, - }) => { - try { - if (!session.activeOrganizationId) { - throw new Error("User organization not found"); - } + .schema( + z.object({ + integration_id: z.string(), + option: z.object({ + id: z.string(), + value: z.unknown(), + }), + }), + ) + .metadata({ + name: "update-integration-settings", + track: { + event: "update-integration-settings", + channel: "update-integration-settings", + }, + }) + .action( + async ({ parsedInput: { integration_id, option }, ctx: { session } }) => { + try { + if (!session.activeOrganizationId) { + throw new Error("User organization not found"); + } - let existingIntegration = await db.integration.findFirst({ - where: { - name: integration_id, - organizationId: session.activeOrganizationId, - }, - }); + let existingIntegration = await db.integration.findFirst({ + where: { + name: integration_id, + organizationId: session.activeOrganizationId, + }, + }); - if (!existingIntegration) { - existingIntegration = await db.integration.create({ - data: { - name: integration_id, - organizationId: session.activeOrganizationId, - userSettings: {}, - integrationId: integration_id, - settings: {}, - }, - }); - } + if (!existingIntegration) { + existingIntegration = await db.integration.create({ + data: { + name: integration_id, + organizationId: session.activeOrganizationId, + userSettings: {}, + integrationId: integration_id, + settings: {}, + }, + }); + } - const userSettings = existingIntegration.userSettings; + const userSettings = existingIntegration.userSettings; - if (!userSettings) { - throw new Error("User settings not found"); - } + if (!userSettings) { + throw new Error("User settings not found"); + } - const updatedUserSettings = { - ...(userSettings as Record), - [option.id]: option.value, - }; + const updatedUserSettings = { + ...(userSettings as Record), + [option.id]: option.value, + }; - const parsedUserSettings = JSON.parse( - JSON.stringify(updatedUserSettings), - ); + const parsedUserSettings = JSON.parse( + JSON.stringify(updatedUserSettings), + ); - const encryptedSettings = await Promise.all( - Object.entries(parsedUserSettings).map( - async ([key, value]) => { - if (typeof value === "string") { - const encrypted = await encrypt(value); - return [key, encrypted]; - } - return [key, value]; - }, - ), - ).then(Object.fromEntries); + const encryptedSettings = await Promise.all( + Object.entries(parsedUserSettings).map(async ([key, value]) => { + if (typeof value === "string") { + const encrypted = await encrypt(value); + return [key, encrypted]; + } + return [key, value]; + }), + ).then(Object.fromEntries); - await db.integration.update({ - where: { - id: existingIntegration.id, - }, - data: { - userSettings: encryptedSettings, - }, - }); + await db.integration.update({ + where: { + id: existingIntegration.id, + }, + data: { + userSettings: encryptedSettings, + }, + }); - revalidatePath("/integrations"); + revalidatePath("/integrations"); - return { success: true }; - } catch (error) { - console.error("Failed to update integration settings:", error); - return { - success: false, - error: - error instanceof Error - ? error.message - : "Failed to update integration settings", - }; - } - }, - ); + return { success: true }; + } catch (error) { + console.error("Failed to update integration settings:", error); + return { + success: false, + error: + error instanceof Error + ? error.message + : "Failed to update integration settings", + }; + } + }, + ); diff --git a/apps/app/src/actions/organization/accept-invitation.ts b/apps/app/src/actions/organization/accept-invitation.ts index 1d0c6e6db6..0f7925fe7d 100644 --- a/apps/app/src/actions/organization/accept-invitation.ts +++ b/apps/app/src/actions/organization/accept-invitation.ts @@ -8,169 +8,156 @@ import { authActionClient } from "../safe-action"; import type { ActionResponse } from "../types"; async function validateInviteCode(inviteCode: string, invitedEmail: string) { - const pendingInvitation = await db.invitation.findFirst({ - where: { - status: "pending", - email: invitedEmail, - id: inviteCode, - }, - include: { - organization: { - select: { - id: true, - name: true, - }, - }, - }, - }); - - return pendingInvitation; + const pendingInvitation = await db.invitation.findFirst({ + where: { + status: "pending", + email: invitedEmail, + id: inviteCode, + }, + include: { + organization: { + select: { + id: true, + name: true, + }, + }, + }, + }); + + return pendingInvitation; } const completeInvitationSchema = z.object({ - inviteCode: z.string(), + inviteCode: z.string(), }); export const completeInvitation = authActionClient - .metadata({ - name: "complete-invitation", - track: { - event: "complete_invitation", - channel: "organization", - }, - }) - .schema(completeInvitationSchema) - .action( - async ({ - parsedInput, - ctx, - }): Promise< - ActionResponse<{ - accepted: boolean; - organizationId: string; - }> - > => { - const { inviteCode } = parsedInput; - const user = ctx.user; - - if (!user || !user.email) { - throw new Error("Unauthorized"); - } - - try { - const invitation = await validateInviteCode( - inviteCode, - user.email, - ); - - if (!invitation) { - throw new Error("Invitation either used or expired"); - } - - const existingMembership = await db.member.findFirst({ - where: { - userId: user.id, - organizationId: invitation.organizationId, - }, - }); - - if (existingMembership) { - if ( - ctx.session.activeOrganizationId !== - invitation.organizationId - ) { - await db.session.update({ - where: { id: ctx.session.id }, - data: { - activeOrganizationId: invitation.organizationId, - }, - }); - } - - await db.invitation.update({ - where: { id: invitation.id }, - data: { - status: "accepted", - }, - }); - - return { - success: true, - data: { - accepted: true, - organizationId: invitation.organizationId, - }, - }; - } - - if (!invitation.role) { - throw new Error("Invitation role is required"); - } - - await db.member.create({ - data: { - userId: user.id, - organizationId: invitation.organizationId, - role: invitation.role, - department: "none", - }, - }); - - await db.invitation.update({ - where: { - id: invitation.id, - }, - data: { - status: "accepted", - }, - }); - - await db.session.update({ - where: { - id: ctx.session.id, - }, - data: { - activeOrganizationId: invitation.organizationId, - }, - }); - - if ( - process.env.RESEND_API_KEY && - process.env.RESEND_AUDIENCE_ID - ) { - const resend = new Resend(process.env.RESEND_API_KEY); - - await resend.contacts.create({ - firstName: - (user.name?.split(" ")[0] || "") - .charAt(0) - .toUpperCase() + - (user.name?.split(" ")[0] || "").slice(1), - lastName: - (user.name?.split(" ")[1] || "") - .charAt(0) - .toUpperCase() + - (user.name?.split(" ")[1] || "").slice(1), - email: user.email, - unsubscribed: false, - audienceId: process.env.RESEND_AUDIENCE_ID, - }); - } - - revalidatePath(`/${invitation.organization.id}`); - revalidatePath(`/${invitation.organization.id}/settings/users`); - revalidateTag(`user_${user.id}`); - - return { - success: true, - data: { - accepted: true, - organizationId: invitation.organizationId, - }, - }; - } catch (error) { - console.error("Error accepting invitation:", error); - throw new Error(error as string); - } - }, - ); + .metadata({ + name: "complete-invitation", + track: { + event: "complete_invitation", + channel: "organization", + }, + }) + .schema(completeInvitationSchema) + .action( + async ({ + parsedInput, + ctx, + }): Promise< + ActionResponse<{ + accepted: boolean; + organizationId: string; + }> + > => { + const { inviteCode } = parsedInput; + const user = ctx.user; + + if (!user || !user.email) { + throw new Error("Unauthorized"); + } + + try { + const invitation = await validateInviteCode(inviteCode, user.email); + + if (!invitation) { + throw new Error("Invitation either used or expired"); + } + + const existingMembership = await db.member.findFirst({ + where: { + userId: user.id, + organizationId: invitation.organizationId, + }, + }); + + if (existingMembership) { + if (ctx.session.activeOrganizationId !== invitation.organizationId) { + await db.session.update({ + where: { id: ctx.session.id }, + data: { + activeOrganizationId: invitation.organizationId, + }, + }); + } + + await db.invitation.update({ + where: { id: invitation.id }, + data: { + status: "accepted", + }, + }); + + return { + success: true, + data: { + accepted: true, + organizationId: invitation.organizationId, + }, + }; + } + + if (!invitation.role) { + throw new Error("Invitation role is required"); + } + + await db.member.create({ + data: { + userId: user.id, + organizationId: invitation.organizationId, + role: invitation.role, + department: "none", + }, + }); + + await db.invitation.update({ + where: { + id: invitation.id, + }, + data: { + status: "accepted", + }, + }); + + await db.session.update({ + where: { + id: ctx.session.id, + }, + data: { + activeOrganizationId: invitation.organizationId, + }, + }); + + if (process.env.RESEND_API_KEY && process.env.RESEND_AUDIENCE_ID) { + const resend = new Resend(process.env.RESEND_API_KEY); + + await resend.contacts.create({ + firstName: + (user.name?.split(" ")[0] || "").charAt(0).toUpperCase() + + (user.name?.split(" ")[0] || "").slice(1), + lastName: + (user.name?.split(" ")[1] || "").charAt(0).toUpperCase() + + (user.name?.split(" ")[1] || "").slice(1), + email: user.email, + unsubscribed: false, + audienceId: process.env.RESEND_AUDIENCE_ID, + }); + } + + revalidatePath(`/${invitation.organization.id}`); + revalidatePath(`/${invitation.organization.id}/settings/users`); + revalidateTag(`user_${user.id}`); + + return { + success: true, + data: { + accepted: true, + organizationId: invitation.organizationId, + }, + }; + } catch (error) { + console.error("Error accepting invitation:", error); + throw new Error(error as string); + } + }, + ); diff --git a/apps/app/src/actions/organization/add-frameworks-to-organization-action.ts b/apps/app/src/actions/organization/add-frameworks-to-organization-action.ts index a8ec7cc621..ed5a4e43b2 100644 --- a/apps/app/src/actions/organization/add-frameworks-to-organization-action.ts +++ b/apps/app/src/actions/organization/add-frameworks-to-organization-action.ts @@ -16,51 +16,58 @@ export type AddFrameworksInput = z.infer; * already exist (e.g., from a shared template or a previous addition). */ export const addFrameworksToOrganizationAction = async ( - input: AddFrameworksInput, + input: AddFrameworksInput, ): Promise<{ success: boolean; error?: string }> => { - try { - const validatedInput = addFrameworksSchema.parse(input); - const { frameworkIds, organizationId } = validatedInput; + try { + const validatedInput = addFrameworksSchema.parse(input); + const { frameworkIds, organizationId } = validatedInput; - await db.$transaction(async (tx) => { - // 1. Fetch FrameworkEditorFrameworks and their requirements for the given frameworkIds, filtering by visible: true - const frameworksAndRequirements = await tx.frameworkEditorFramework.findMany({ - where: { - id: { in: frameworkIds }, - visible: true, - }, - include: { - requirements: true, - }, - }); + await db.$transaction(async (tx) => { + // 1. Fetch FrameworkEditorFrameworks and their requirements for the given frameworkIds, filtering by visible: true + const frameworksAndRequirements = + await tx.frameworkEditorFramework.findMany({ + where: { + id: { in: frameworkIds }, + visible: true, + }, + include: { + requirements: true, + }, + }); - if (frameworksAndRequirements.length === 0) { - throw new Error("No valid or visible frameworks found for the provided IDs."); - } + if (frameworksAndRequirements.length === 0) { + throw new Error( + "No valid or visible frameworks found for the provided IDs.", + ); + } - const finalFrameworkEditorIds = frameworksAndRequirements.map(f => f.id); + const finalFrameworkEditorIds = frameworksAndRequirements.map( + (f) => f.id, + ); - // 2. Call the renamed core function - await _upsertOrgFrameworkStructureCore({ - organizationId, - targetFrameworkEditorIds: finalFrameworkEditorIds, - frameworkEditorFrameworks: frameworksAndRequirements, - tx: tx as unknown as Prisma.TransactionClient // Use the transaction client from this action - }); + // 2. Call the renamed core function + await _upsertOrgFrameworkStructureCore({ + organizationId, + targetFrameworkEditorIds: finalFrameworkEditorIds, + frameworkEditorFrameworks: frameworksAndRequirements, + tx: tx as unknown as Prisma.TransactionClient, // Use the transaction client from this action + }); - // The rest of the logic (creating instances, relations) is now inside _upsertOrgFrameworkStructureCore - }); + // The rest of the logic (creating instances, relations) is now inside _upsertOrgFrameworkStructureCore + }); - revalidatePath("/"); // Revalidate all paths, or be more specific e.g. /${organizationId}/frameworks - return { success: true }; - - } catch (error) { - console.error("Error in addFrameworksToOrganizationAction:", error); - if (error instanceof z.ZodError) { - return { success: false, error: error.errors.map(e => e.message).join(", ") }; - } else if (error instanceof Error) { - return { success: false, error: error.message }; - } - return { success: false, error: "An unexpected error occurred." }; - } -}; \ No newline at end of file + revalidatePath("/"); // Revalidate all paths, or be more specific e.g. /${organizationId}/frameworks + return { success: true }; + } catch (error) { + console.error("Error in addFrameworksToOrganizationAction:", error); + if (error instanceof z.ZodError) { + return { + success: false, + error: error.errors.map((e) => e.message).join(", "), + }; + } else if (error instanceof Error) { + return { success: false, error: error.message }; + } + return { success: false, error: "An unexpected error occurred." }; + } +}; diff --git a/apps/app/src/actions/organization/bulk-invite-employees.ts b/apps/app/src/actions/organization/bulk-invite-employees.ts index 1446147e77..c6d5e9ebba 100644 --- a/apps/app/src/actions/organization/bulk-invite-employees.ts +++ b/apps/app/src/actions/organization/bulk-invite-employees.ts @@ -9,51 +9,49 @@ import { headers } from "next/headers"; const emailSchema = z.string().email({ message: "Invalid email format" }); const schema = z.object({ - organizationId: z.string(), - emails: z - .array(emailSchema) - .min(1, { message: "At least one email is required." }), + organizationId: z.string(), + emails: z + .array(emailSchema) + .min(1, { message: "At least one email is required." }), }); interface InviteResult { - email: string; - success: boolean; - error?: string; + email: string; + success: boolean; + error?: string; } export const bulkInviteEmployees = createSafeActionClient() - .schema(schema) - .action(async ({ parsedInput }) => { - const { organizationId, emails } = parsedInput; - - const session = await auth.api.getSession({ headers: await headers() }); - if (session?.session.activeOrganizationId !== organizationId) { - return { - success: false, - error: "Unauthorized or invalid organization.", - }; - } - - const results: InviteResult[] = []; - let allSuccess = true; - - for (const email of emails) { - try { - await authClient.organization.inviteMember({ - email: email, - role: "employee", - }); - results.push({ email, success: true }); - } catch (error) { - allSuccess = false; - console.error(`Failed to invite ${email}:`, error); - const errorMessage = - error instanceof Error - ? error.message - : "Invitation failed"; - results.push({ email, success: false, error: errorMessage }); - } - } - - return { success: true, data: results }; - }); + .schema(schema) + .action(async ({ parsedInput }) => { + const { organizationId, emails } = parsedInput; + + const session = await auth.api.getSession({ headers: await headers() }); + if (session?.session.activeOrganizationId !== organizationId) { + return { + success: false, + error: "Unauthorized or invalid organization.", + }; + } + + const results: InviteResult[] = []; + let allSuccess = true; + + for (const email of emails) { + try { + await authClient.organization.inviteMember({ + email: email, + role: "employee", + }); + results.push({ email, success: true }); + } catch (error) { + allSuccess = false; + console.error(`Failed to invite ${email}:`, error); + const errorMessage = + error instanceof Error ? error.message : "Invitation failed"; + results.push({ email, success: false, error: errorMessage }); + } + } + + return { success: true, data: results }; + }); diff --git a/apps/app/src/actions/organization/create-api-key-action.ts b/apps/app/src/actions/organization/create-api-key-action.ts index 063889f49d..1cedc62c14 100644 --- a/apps/app/src/actions/organization/create-api-key-action.ts +++ b/apps/app/src/actions/organization/create-api-key-action.ts @@ -7,128 +7,113 @@ import { db } from "@comp/db"; import { revalidatePath } from "next/cache"; export const createApiKeyAction = authActionClient - .schema(apiKeySchema) - .metadata({ - name: "createApiKey", - track: { - event: "createApiKey", - channel: "server", - }, - }) - .action(async ({ parsedInput, ctx }) => { - try { - const { name, expiresAt } = parsedInput; - console.log( - `Creating API key "${name}" with expiration: ${expiresAt}`, - ); + .schema(apiKeySchema) + .metadata({ + name: "createApiKey", + track: { + event: "createApiKey", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + try { + const { name, expiresAt } = parsedInput; + console.log(`Creating API key "${name}" with expiration: ${expiresAt}`); - // Generate a new API key and salt - const apiKey = generateApiKey(); - const salt = generateSalt(); - const hashedKey = hashApiKey(apiKey, salt); - console.log( - `Generated new API key for organization: ${ctx.session.activeOrganizationId}`, - ); + // Generate a new API key and salt + const apiKey = generateApiKey(); + const salt = generateSalt(); + const hashedKey = hashApiKey(apiKey, salt); + console.log( + `Generated new API key for organization: ${ctx.session.activeOrganizationId}`, + ); - // Parse the expiration date - let expirationDate: Date | null = null; - if (expiresAt && expiresAt !== "never") { - const now = new Date(); - switch (expiresAt) { - case "30days": - expirationDate = new Date( - now.setDate(now.getDate() + 30), - ); - break; - case "90days": - expirationDate = new Date( - now.setDate(now.getDate() + 90), - ); - break; - case "1year": - expirationDate = new Date( - now.setFullYear(now.getFullYear() + 1), - ); - break; - } - console.log( - `Set expiration date to: ${expirationDate?.toISOString()}`, - ); - } else { - console.log("No expiration date set for API key"); - } + // Parse the expiration date + let expirationDate: Date | null = null; + if (expiresAt && expiresAt !== "never") { + const now = new Date(); + switch (expiresAt) { + case "30days": + expirationDate = new Date(now.setDate(now.getDate() + 30)); + break; + case "90days": + expirationDate = new Date(now.setDate(now.getDate() + 90)); + break; + case "1year": + expirationDate = new Date(now.setFullYear(now.getFullYear() + 1)); + break; + } + console.log(`Set expiration date to: ${expirationDate?.toISOString()}`); + } else { + console.log("No expiration date set for API key"); + } - // Create the API key in the database - const apiKeyRecord = await db.apiKey.create({ - data: { - name, - key: hashedKey, - salt, // Store the salt with the hashed key - expiresAt: expirationDate, - organizationId: ctx.session.activeOrganizationId!, - }, - select: { - id: true, - name: true, - createdAt: true, - expiresAt: true, - }, - }); - console.log( - `Successfully created API key with ID: ${apiKeyRecord.id}`, - ); + // Create the API key in the database + const apiKeyRecord = await db.apiKey.create({ + data: { + name, + key: hashedKey, + salt, // Store the salt with the hashed key + expiresAt: expirationDate, + organizationId: ctx.session.activeOrganizationId!, + }, + select: { + id: true, + name: true, + createdAt: true, + expiresAt: true, + }, + }); + console.log(`Successfully created API key with ID: ${apiKeyRecord.id}`); - revalidatePath( - `/${ctx.session.activeOrganizationId}/settings/api-keys`, - ); + revalidatePath(`/${ctx.session.activeOrganizationId}/settings/api-keys`); - return { - success: true, - data: { - ...apiKeyRecord, - key: apiKey, - createdAt: apiKeyRecord.createdAt.toISOString(), - expiresAt: apiKeyRecord.expiresAt - ? apiKeyRecord.expiresAt.toISOString() - : null, - }, - }; - } catch (error) { - console.error("Error creating API key:", error); + return { + success: true, + data: { + ...apiKeyRecord, + key: apiKey, + createdAt: apiKeyRecord.createdAt.toISOString(), + expiresAt: apiKeyRecord.expiresAt + ? apiKeyRecord.expiresAt.toISOString() + : null, + }, + }; + } catch (error) { + console.error("Error creating API key:", error); - // Provide more specific error messages based on error type - if (error instanceof Error) { - console.error(`Error details: ${error.message}`); + // Provide more specific error messages based on error type + if (error instanceof Error) { + console.error(`Error details: ${error.message}`); - if (error.message.includes("Unique constraint")) { - return { - success: false, - error: { - code: "DUPLICATE_NAME", - message: "An API key with this name already exists", - }, - }; - } + if (error.message.includes("Unique constraint")) { + return { + success: false, + error: { + code: "DUPLICATE_NAME", + message: "An API key with this name already exists", + }, + }; + } - if (error.message.includes("Foreign key constraint")) { - return { - success: false, - error: { - code: "INVALID_ORGANIZATION", - message: - "The organization does not exist or you don't have access", - }, - }; - } - } + if (error.message.includes("Foreign key constraint")) { + return { + success: false, + error: { + code: "INVALID_ORGANIZATION", + message: + "The organization does not exist or you don't have access", + }, + }; + } + } - return { - success: false, - error: { - code: "INTERNAL_ERROR", - message: - "An unexpected error occurred while creating the API key", - }, - }; - } - }); + return { + success: false, + error: { + code: "INTERNAL_ERROR", + message: "An unexpected error occurred while creating the API key", + }, + }; + } + }); diff --git a/apps/app/src/actions/organization/create-organization-action.ts b/apps/app/src/actions/organization/create-organization-action.ts index 8c718df674..634b5b7834 100644 --- a/apps/app/src/actions/organization/create-organization-action.ts +++ b/apps/app/src/actions/organization/create-organization-action.ts @@ -8,83 +8,86 @@ import { authActionClient } from "../safe-action"; import { organizationSchema } from "../schema"; import { createStripeCustomer } from "./lib/create-stripe-customer"; import { initializeOrganization } from "./lib/initialize-organization"; +import { createFleetLabelForOrg } from "@/jobs/tasks/device/create-fleet-label-for-org"; export const createOrganizationAction = authActionClient - .schema(organizationSchema) - .metadata({ - name: "create-organization", - track: { - event: "create-organization", - channel: "server", - }, - }) - .action(async ({ parsedInput, ctx }) => { - const { frameworkIds } = parsedInput; - - try { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - if (!session?.session.activeOrganizationId) { - throw new Error("User is not part of an organization"); - } - - await db.onboarding.create({ - data: { - organizationId: session.session.activeOrganizationId, - completed: false, - }, - }); - - const organizationId = session.session.activeOrganizationId; - - const stripeCustomerId = await createStripeCustomer({ - name: "My Organization", - email: session.user.email, - organizationId, - }); - - if (!stripeCustomerId) { - throw new Error("Failed to create Stripe customer"); - } - - await db.organization.update({ - where: { id: organizationId }, - data: { stripeCustomerId }, - }); - - await initializeOrganization({ frameworkIds, organizationId }); - - await auth.api.setActiveOrganization({ - headers: await headers(), - body: { - organizationId, - }, - }); - - const userOrgs = await db.member.findMany({ - where: { - userId: session.user.id, - }, - select: { - organizationId: true, - }, - }); - - for (const org of userOrgs) { - revalidatePath(`/${org.organizationId}`); - } - - return { - success: true, - organizationId, - }; - } catch (error) { - console.error("Error during organization creation/update:", error); - - throw new Error( - "Failed to create or update organization structure", - ); - } - }); + .schema(organizationSchema) + .metadata({ + name: "create-organization", + track: { + event: "create-organization", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { frameworkIds } = parsedInput; + + try { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session?.session.activeOrganizationId) { + throw new Error("User is not part of an organization"); + } + + await db.onboarding.create({ + data: { + organizationId: session.session.activeOrganizationId, + completed: false, + }, + }); + + const organizationId = session.session.activeOrganizationId; + + const stripeCustomerId = await createStripeCustomer({ + name: "My Organization", + email: session.user.email, + organizationId, + }); + + if (!stripeCustomerId) { + throw new Error("Failed to create Stripe customer"); + } + + await db.organization.update({ + where: { id: organizationId }, + data: { stripeCustomerId }, + }); + + await initializeOrganization({ frameworkIds, organizationId }); + + await auth.api.setActiveOrganization({ + headers: await headers(), + body: { + organizationId, + }, + }); + + const userOrgs = await db.member.findMany({ + where: { + userId: session.user.id, + }, + select: { + organizationId: true, + }, + }); + + for (const org of userOrgs) { + revalidatePath(`/${org.organizationId}`); + } + + await createFleetLabelForOrg.trigger({ + organizationId, + }); + + return { + success: true, + organizationId, + }; + } catch (error) { + console.error("Error during organization creation/update:", error); + + throw new Error("Failed to create or update organization structure"); + } + }); diff --git a/apps/app/src/actions/organization/delete-organization-action.ts b/apps/app/src/actions/organization/delete-organization-action.ts index 6a6c820029..25c152c487 100644 --- a/apps/app/src/actions/organization/delete-organization-action.ts +++ b/apps/app/src/actions/organization/delete-organization-action.ts @@ -8,46 +8,46 @@ import { authActionClient } from "../safe-action"; import { deleteOrganizationSchema } from "../schema"; type DeleteOrganizationResult = { - success: boolean; - redirect?: string; + success: boolean; + redirect?: string; }; export const deleteOrganizationAction = authActionClient - .schema(deleteOrganizationSchema) - .metadata({ - name: "delete-organization", - track: { - event: "delete-organization", - channel: "server", - }, - }) - .action(async ({ parsedInput, ctx }): Promise => { - const { id } = parsedInput; - const { session } = ctx; - - if (!id) { - throw new Error("Invalid user input"); - } - - if (!session.activeOrganizationId) { - throw new Error("Invalid organization input"); - } - - try { - await db.$transaction(async () => { - await db.organization.delete({ - where: { id: session.activeOrganizationId ?? "" }, - }); - }); - - revalidatePath(`/${session.activeOrganizationId}`); - - return { - success: true, - }; - } catch (error) { - return { - success: false, - }; - } - }); + .schema(deleteOrganizationSchema) + .metadata({ + name: "delete-organization", + track: { + event: "delete-organization", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }): Promise => { + const { id } = parsedInput; + const { session } = ctx; + + if (!id) { + throw new Error("Invalid user input"); + } + + if (!session.activeOrganizationId) { + throw new Error("Invalid organization input"); + } + + try { + await db.$transaction(async () => { + await db.organization.delete({ + where: { id: session.activeOrganizationId ?? "" }, + }); + }); + + revalidatePath(`/${session.activeOrganizationId}`); + + return { + success: true, + }; + } catch (error) { + return { + success: false, + }; + } + }); diff --git a/apps/app/src/actions/organization/get-api-keys-action.ts b/apps/app/src/actions/organization/get-api-keys-action.ts index 6edabda53c..6e548412fe 100644 --- a/apps/app/src/actions/organization/get-api-keys-action.ts +++ b/apps/app/src/actions/organization/get-api-keys-action.ts @@ -6,69 +6,67 @@ import { db } from "@comp/db"; import { headers } from "next/headers"; export const getApiKeysAction = async (): Promise< - ActionResponse< - { - id: string; - name: string; - createdAt: string; - expiresAt: string | null; - lastUsedAt: string | null; - isActive: boolean; - }[] - > + ActionResponse< + { + id: string; + name: string; + createdAt: string; + expiresAt: string | null; + lastUsedAt: string | null; + isActive: boolean; + }[] + > > => { - try { - const session = await auth.api.getSession({ - headers: await headers(), - }); + try { + const session = await auth.api.getSession({ + headers: await headers(), + }); - if (!session?.session.activeOrganizationId) { - return { - success: false, - error: { - code: "UNAUTHORIZED", - message: "You must be logged in to perform this action", - }, - }; - } + if (!session?.session.activeOrganizationId) { + return { + success: false, + error: { + code: "UNAUTHORIZED", + message: "You must be logged in to perform this action", + }, + }; + } - const apiKeys = await db.apiKey.findMany({ - where: { - organizationId: session.session.activeOrganizationId, - isActive: true, - }, - select: { - id: true, - name: true, - createdAt: true, - expiresAt: true, - lastUsedAt: true, - isActive: true, - }, - orderBy: { - createdAt: "desc", - }, - }); + const apiKeys = await db.apiKey.findMany({ + where: { + organizationId: session.session.activeOrganizationId, + isActive: true, + }, + select: { + id: true, + name: true, + createdAt: true, + expiresAt: true, + lastUsedAt: true, + isActive: true, + }, + orderBy: { + createdAt: "desc", + }, + }); - return { - success: true, - data: apiKeys.map((key) => ({ - ...key, - createdAt: key.createdAt.toISOString(), - expiresAt: key.expiresAt ? key.expiresAt.toISOString() : null, - lastUsedAt: key.lastUsedAt - ? key.lastUsedAt.toISOString() - : null, - })), - }; - } catch (error) { - console.error("Error fetching API keys:", error); - return { - success: false, - error: { - code: "INTERNAL_ERROR", - message: "An error occurred while fetching API keys", - }, - }; - } + return { + success: true, + data: apiKeys.map((key) => ({ + ...key, + createdAt: key.createdAt.toISOString(), + expiresAt: key.expiresAt ? key.expiresAt.toISOString() : null, + lastUsedAt: key.lastUsedAt ? key.lastUsedAt.toISOString() : null, + })), + }; + } catch (error) { + console.error("Error fetching API keys:", error); + return { + success: false, + error: { + code: "INTERNAL_ERROR", + message: "An error occurred while fetching API keys", + }, + }; + } }; diff --git a/apps/app/src/actions/organization/get-organization-users-action.ts b/apps/app/src/actions/organization/get-organization-users-action.ts index 54a7f0f908..2c21628982 100644 --- a/apps/app/src/actions/organization/get-organization-users-action.ts +++ b/apps/app/src/actions/organization/get-organization-users-action.ts @@ -4,61 +4,61 @@ import { db } from "@comp/db"; import { authActionClient } from "../safe-action"; interface User { - id: string; - name: string | null; - image: string | null; + id: string; + name: string | null; + image: string | null; } export const getOrganizationUsersAction = authActionClient - .metadata({ - name: "get-organization-users", - }) - .action( - async ({ - parsedInput, - ctx, - }): Promise<{ success: boolean; error?: string; data?: User[] }> => { - if (!ctx.session.activeOrganizationId) { - return { - success: false, - error: "User does not have an organization", - }; - } + .metadata({ + name: "get-organization-users", + }) + .action( + async ({ + parsedInput, + ctx, + }): Promise<{ success: boolean; error?: string; data?: User[] }> => { + if (!ctx.session.activeOrganizationId) { + return { + success: false, + error: "User does not have an organization", + }; + } - try { - const users = await db.member.findMany({ - where: { - organizationId: ctx.session.activeOrganizationId, - }, - select: { - user: { - select: { - id: true, - name: true, - image: true, - }, - }, - }, - orderBy: { - user: { - name: "asc", - }, - }, - }); + try { + const users = await db.member.findMany({ + where: { + organizationId: ctx.session.activeOrganizationId, + }, + select: { + user: { + select: { + id: true, + name: true, + image: true, + }, + }, + }, + orderBy: { + user: { + name: "asc", + }, + }, + }); - return { - success: true, - data: users.map((user) => ({ - id: user.user.id, - name: user.user.name || "", - image: user.user.image || "", - })), - }; - } catch (error) { - return { - success: false, - error: "Failed to fetch organization users", - }; - } - }, - ); + return { + success: true, + data: users.map((user) => ({ + id: user.user.id, + name: user.user.name || "", + image: user.user.image || "", + })), + }; + } catch (error) { + return { + success: false, + error: "Failed to fetch organization users", + }; + } + }, + ); diff --git a/apps/app/src/actions/organization/invite-employee.ts b/apps/app/src/actions/organization/invite-employee.ts index 72317768fb..ad29831c2c 100644 --- a/apps/app/src/actions/organization/invite-employee.ts +++ b/apps/app/src/actions/organization/invite-employee.ts @@ -10,58 +10,56 @@ import type { ActionResponse } from "../types"; // Schema only needs email now const inviteEmployeeSchema = z.object({ - email: z.string().email(), + email: z.string().email(), }); export const inviteEmployee = authActionClient - .metadata({ - name: "invite-employee", // Updated name - track: { - event: "invite_employee", // Updated event name - channel: "organization", - }, - }) - .schema(inviteEmployeeSchema) - .action( - async ({ - parsedInput, - ctx, - }): Promise> => { - const organizationId = ctx.session.activeOrganizationId; + .metadata({ + name: "invite-employee", // Updated name + track: { + event: "invite_employee", // Updated event name + channel: "organization", + }, + }) + .schema(inviteEmployeeSchema) + .action( + async ({ + parsedInput, + ctx, + }): Promise> => { + const organizationId = ctx.session.activeOrganizationId; - if (!organizationId) { - return { - success: false, - error: "Organization not found", - }; - } + if (!organizationId) { + return { + success: false, + error: "Organization not found", + }; + } - const { email } = parsedInput; // Role is removed from input + const { email } = parsedInput; // Role is removed from input - try { - await authClient.organization.inviteMember({ - email, - role: "employee", // Hardcoded role - }); + try { + await authClient.organization.inviteMember({ + email, + role: "employee", // Hardcoded role + }); - // Revalidate the employees list page - revalidatePath(`/${organizationId}/people/all`); - revalidateTag(`user_${ctx.user.id}`); // Keep user tag revalidation + // Revalidate the employees list page + revalidatePath(`/${organizationId}/people/all`); + revalidateTag(`user_${ctx.user.id}`); // Keep user tag revalidation - return { - success: true, - data: { invited: true }, - }; - } catch (error) { - console.error("Error inviting employee:", error); - const errorMessage = - error instanceof Error - ? error.message - : "Failed to invite employee"; - return { - success: false, - error: errorMessage, - }; - } - }, - ); + return { + success: true, + data: { invited: true }, + }; + } catch (error) { + console.error("Error inviting employee:", error); + const errorMessage = + error instanceof Error ? error.message : "Failed to invite employee"; + return { + success: false, + error: errorMessage, + }; + } + }, + ); diff --git a/apps/app/src/actions/organization/invite-member.ts b/apps/app/src/actions/organization/invite-member.ts index abee0de6bd..bcc217a5ce 100644 --- a/apps/app/src/actions/organization/invite-member.ts +++ b/apps/app/src/actions/organization/invite-member.ts @@ -9,58 +9,56 @@ import { authActionClient } from "../safe-action"; import type { ActionResponse } from "../types"; const inviteMemberSchema = z.object({ - email: z.string().email(), - role: z.enum(["owner", "admin", "auditor", "employee"]), + email: z.string().email(), + role: z.enum(["owner", "admin", "auditor", "employee"]), }); export const inviteMember = authActionClient - .metadata({ - name: "invite-member", - track: { - event: "invite_member", - channel: "organization", - }, - }) - .schema(inviteMemberSchema) - .action( - async ({ - parsedInput, - ctx, - }): Promise> => { - const organizationId = ctx.session.activeOrganizationId; + .metadata({ + name: "invite-member", + track: { + event: "invite_member", + channel: "organization", + }, + }) + .schema(inviteMemberSchema) + .action( + async ({ + parsedInput, + ctx, + }): Promise> => { + const organizationId = ctx.session.activeOrganizationId; - if (!organizationId) { - return { - success: false, - error: "Organization not found", - }; - } + if (!organizationId) { + return { + success: false, + error: "Organization not found", + }; + } - const { email, role } = parsedInput; + const { email, role } = parsedInput; - try { - await authClient.organization.inviteMember({ - email, - role, - }); + try { + await authClient.organization.inviteMember({ + email, + role, + }); - revalidatePath(`/${organizationId}/settings/users`); - revalidateTag(`user_${ctx.user.id}`); + revalidatePath(`/${organizationId}/settings/users`); + revalidateTag(`user_${ctx.user.id}`); - return { - success: true, - data: { invited: true }, - }; - } catch (error) { - console.error("Error inviting member:", error); - const errorMessage = - error instanceof Error - ? error.message - : "Failed to invite member"; - return { - success: false, - error: errorMessage, - }; - } - }, - ); + return { + success: true, + data: { invited: true }, + }; + } catch (error) { + console.error("Error inviting member:", error); + const errorMessage = + error instanceof Error ? error.message : "Failed to invite member"; + return { + success: false, + error: errorMessage, + }; + } + }, + ); diff --git a/apps/app/src/actions/organization/lib/create-stripe-customer.ts b/apps/app/src/actions/organization/lib/create-stripe-customer.ts index 6a47dcdbde..5271b59392 100644 --- a/apps/app/src/actions/organization/lib/create-stripe-customer.ts +++ b/apps/app/src/actions/organization/lib/create-stripe-customer.ts @@ -1,28 +1,28 @@ import { stripe } from "./stripe"; async function createStripeCustomer(input: { - name: string; - email: string; - organizationId: string; + name: string; + email: string; + organizationId: string; }): Promise { - try { - if (!stripe) { - return "test_customer_id"; - } + try { + if (!stripe) { + return "test_customer_id"; + } - const customer = await stripe.customers.create({ - name: input.name, - email: input.email, - metadata: { - organizationId: input.organizationId, - }, - }); + const customer = await stripe.customers.create({ + name: input.name, + email: input.email, + metadata: { + organizationId: input.organizationId, + }, + }); - return customer.id; - } catch (error) { - console.error("Error creating Stripe customer", error); - throw error; - } + return customer.id; + } catch (error) { + console.error("Error creating Stripe customer", error); + throw error; + } } export { createStripeCustomer }; diff --git a/apps/app/src/actions/organization/lib/initialize-organization.ts b/apps/app/src/actions/organization/lib/initialize-organization.ts index ece9647337..1adbdf5307 100644 --- a/apps/app/src/actions/organization/lib/initialize-organization.ts +++ b/apps/app/src/actions/organization/lib/initialize-organization.ts @@ -5,31 +5,31 @@ import { Prisma } from "@comp/db/types"; // This assumes FrameworkEditorFramework and FrameworkEditorRequirement are valid Prisma types. // Adjust if your Prisma client exposes these differently (e.g., via Prisma.FrameworkEditorFrameworkGetPayload). type FrameworkEditorFrameworkWithRequirements = - Prisma.FrameworkEditorFrameworkGetPayload<{ - include: { requirements: true }; - }>; + Prisma.FrameworkEditorFrameworkGetPayload<{ + include: { requirements: true }; + }>; export type InitializeOrganizationInput = { - frameworkIds: string[]; - organizationId: string; + frameworkIds: string[]; + organizationId: string; }; // Renamed for clarity and broader applicability export type UpsertOrgFrameworkStructureCoreInput = { - organizationId: string; - targetFrameworkEditorIds: string[]; - frameworkEditorFrameworks: FrameworkEditorFrameworkWithRequirements[]; - tx: Prisma.TransactionClient; + organizationId: string; + targetFrameworkEditorIds: string[]; + frameworkEditorFrameworks: FrameworkEditorFrameworkWithRequirements[]; + tx: Prisma.TransactionClient; }; // Renamed for clarity and broader applicability export const _upsertOrgFrameworkStructureCore = async ({ - organizationId, - targetFrameworkEditorIds, - frameworkEditorFrameworks, - tx, + organizationId, + targetFrameworkEditorIds, + frameworkEditorFrameworks, + tx, }: UpsertOrgFrameworkStructureCoreInput) => { - /** + /** |-------------------------------------------------- | Get All Template Entities Based on Input Frameworks |-------------------------------------------------- @@ -39,40 +39,40 @@ export const _upsertOrgFrameworkStructureCore = async ({ | TaskTemplates based on ControlTemplates |-------------------------------------------------- */ - const requirementIds = frameworkEditorFrameworks.flatMap((framework) => - framework.requirements.map((req) => req.id), - ); - - const controlTemplates = await tx.frameworkEditorControlTemplate.findMany({ - where: { - requirements: { - some: { - id: { in: requirementIds }, - }, - }, - }, - }); - const controlTemplateIds = controlTemplates.map((control) => control.id); - - const policyTemplates = await tx.frameworkEditorPolicyTemplate.findMany({ - where: { - controlTemplates: { - some: { id: { in: controlTemplateIds } }, - }, - }, - }); - const policyTemplateIds = policyTemplates.map((policy) => policy.id); - - const taskTemplates = await tx.frameworkEditorTaskTemplate.findMany({ - where: { - controlTemplates: { - some: { id: { in: controlTemplateIds } }, - }, - }, - }); - const taskTemplateIds = taskTemplates.map((task) => task.id); - - /** + const requirementIds = frameworkEditorFrameworks.flatMap((framework) => + framework.requirements.map((req) => req.id), + ); + + const controlTemplates = await tx.frameworkEditorControlTemplate.findMany({ + where: { + requirements: { + some: { + id: { in: requirementIds }, + }, + }, + }, + }); + const controlTemplateIds = controlTemplates.map((control) => control.id); + + const policyTemplates = await tx.frameworkEditorPolicyTemplate.findMany({ + where: { + controlTemplates: { + some: { id: { in: controlTemplateIds } }, + }, + }, + }); + const policyTemplateIds = policyTemplates.map((policy) => policy.id); + + const taskTemplates = await tx.frameworkEditorTaskTemplate.findMany({ + where: { + controlTemplates: { + some: { id: { in: controlTemplateIds } }, + }, + }, + }); + const taskTemplateIds = taskTemplates.map((task) => task.id); + + /** |-------------------------------------------------- | Get All Template Relations |-------------------------------------------------- @@ -81,34 +81,30 @@ export const _upsertOrgFrameworkStructureCore = async ({ | ControlTemplates <> TaskTemplates |-------------------------------------------------- */ - const controlRelations = await tx.frameworkEditorControlTemplate.findMany({ - where: { - id: { in: controlTemplateIds }, - }, - select: { - id: true, - requirements: { where: { id: { in: requirementIds } } }, - policyTemplates: { where: { id: { in: policyTemplateIds } } }, - taskTemplates: { where: { id: { in: taskTemplateIds } } }, - }, - }); - - const groupedControlTemplateRelations = controlRelations.map( - (controlTemplate) => ({ - controlTemplateId: controlTemplate.id, - requirementTemplateIds: controlTemplate.requirements.map( - (req) => req.id, - ), - policyTemplateIds: controlTemplate.policyTemplates.map( - (policy) => policy.id, - ), - taskTemplateIds: controlTemplate.taskTemplates.map( - (task) => task.id, - ), - }), - ); - - /** + const controlRelations = await tx.frameworkEditorControlTemplate.findMany({ + where: { + id: { in: controlTemplateIds }, + }, + select: { + id: true, + requirements: { where: { id: { in: requirementIds } } }, + policyTemplates: { where: { id: { in: policyTemplateIds } } }, + taskTemplates: { where: { id: { in: taskTemplateIds } } }, + }, + }); + + const groupedControlTemplateRelations = controlRelations.map( + (controlTemplate) => ({ + controlTemplateId: controlTemplate.id, + requirementTemplateIds: controlTemplate.requirements.map((req) => req.id), + policyTemplateIds: controlTemplate.policyTemplates.map( + (policy) => policy.id, + ), + taskTemplateIds: controlTemplate.taskTemplates.map((task) => task.id), + }), + ); + + /** |-------------------------------------------------- | Upsert Framework Instances |-------------------------------------------------- @@ -116,148 +112,147 @@ export const _upsertOrgFrameworkStructureCore = async ({ | and targetFrameworkEditorIds. Then, fetch all relevant instances (new + existing). |-------------------------------------------------- */ - const existingFrameworkInstances = await tx.frameworkInstance.findMany({ - where: { - organizationId: organizationId, - frameworkId: { in: targetFrameworkEditorIds }, - }, - select: { frameworkId: true }, - }); - const existingFrameworkInstanceFrameworkIds = new Set( - existingFrameworkInstances.map((fi) => fi.frameworkId), - ); - - const frameworkInstancesToCreateData = frameworkEditorFrameworks - .filter( - (f) => - targetFrameworkEditorIds.includes(f.id) && - !existingFrameworkInstanceFrameworkIds.has(f.id), - ) - .map((framework) => ({ - organizationId: organizationId, - frameworkId: framework.id, - })); - - if (frameworkInstancesToCreateData.length > 0) { - await tx.frameworkInstance.createMany({ - data: frameworkInstancesToCreateData, - }); - } - - const allOrgFrameworkInstances = await tx.frameworkInstance.findMany({ - where: { - organizationId: organizationId, - frameworkId: { in: targetFrameworkEditorIds }, - }, - select: { id: true, frameworkId: true }, - }); - const editorFrameworkIdToInstanceIdMap = new Map( - allOrgFrameworkInstances.map((inst) => [inst.frameworkId, inst.id]), - ); - - /** + const existingFrameworkInstances = await tx.frameworkInstance.findMany({ + where: { + organizationId: organizationId, + frameworkId: { in: targetFrameworkEditorIds }, + }, + select: { frameworkId: true }, + }); + const existingFrameworkInstanceFrameworkIds = new Set( + existingFrameworkInstances.map((fi) => fi.frameworkId), + ); + + const frameworkInstancesToCreateData = frameworkEditorFrameworks + .filter( + (f) => + targetFrameworkEditorIds.includes(f.id) && + !existingFrameworkInstanceFrameworkIds.has(f.id), + ) + .map((framework) => ({ + organizationId: organizationId, + frameworkId: framework.id, + })); + + if (frameworkInstancesToCreateData.length > 0) { + await tx.frameworkInstance.createMany({ + data: frameworkInstancesToCreateData, + }); + } + + const allOrgFrameworkInstances = await tx.frameworkInstance.findMany({ + where: { + organizationId: organizationId, + frameworkId: { in: targetFrameworkEditorIds }, + }, + select: { id: true, frameworkId: true }, + }); + const editorFrameworkIdToInstanceIdMap = new Map( + allOrgFrameworkInstances.map((inst) => [inst.frameworkId, inst.id]), + ); + + /** |-------------------------------------------------- | Upsert Control Instances |-------------------------------------------------- */ - const existingControlsQuery = await tx.control.findMany({ - where: { - organizationId: organizationId, - controlTemplateId: { in: controlTemplateIds }, - }, - select: { controlTemplateId: true }, - }); - const existingControlTemplateIdsSet = new Set( - existingControlsQuery - .map((c) => c.controlTemplateId) - .filter((id) => id !== null) as string[], - ); - - const controlTemplatesForCreation = controlTemplates.filter( - (template) => !existingControlTemplateIdsSet.has(template.id), - ); - - if (controlTemplatesForCreation.length > 0) { - await tx.control.createMany({ - data: controlTemplatesForCreation.map((controlTemplate) => ({ - name: controlTemplate.name, - description: controlTemplate.description, - organizationId: organizationId, - controlTemplateId: controlTemplate.id, - })), - }); - } - - /** + const existingControlsQuery = await tx.control.findMany({ + where: { + organizationId: organizationId, + controlTemplateId: { in: controlTemplateIds }, + }, + select: { controlTemplateId: true }, + }); + const existingControlTemplateIdsSet = new Set( + existingControlsQuery + .map((c) => c.controlTemplateId) + .filter((id) => id !== null) as string[], + ); + + const controlTemplatesForCreation = controlTemplates.filter( + (template) => !existingControlTemplateIdsSet.has(template.id), + ); + + if (controlTemplatesForCreation.length > 0) { + await tx.control.createMany({ + data: controlTemplatesForCreation.map((controlTemplate) => ({ + name: controlTemplate.name, + description: controlTemplate.description, + organizationId: organizationId, + controlTemplateId: controlTemplate.id, + })), + }); + } + + /** |-------------------------------------------------- | Upsert Policy Instances |-------------------------------------------------- */ - const existingPoliciesQuery = await tx.policy.findMany({ - where: { - organizationId: organizationId, - policyTemplateId: { in: policyTemplateIds }, - }, - select: { policyTemplateId: true }, - }); - const existingPolicyTemplateIdsSet = new Set( - existingPoliciesQuery - .map((p) => p.policyTemplateId) - .filter((id) => id !== null) as string[], - ); - - const policyTemplatesForCreation = policyTemplates.filter( - (template) => !existingPolicyTemplateIdsSet.has(template.id), - ); - - if (policyTemplatesForCreation.length > 0) { - await tx.policy.createMany({ - data: policyTemplatesForCreation.map((policyTemplate) => ({ - name: policyTemplate.name, - description: policyTemplate.description, - department: policyTemplate.department, - frequency: policyTemplate.frequency, - content: - policyTemplate.content as Prisma.PolicyCreateInput["content"], - organizationId: organizationId, - policyTemplateId: policyTemplate.id, - })), - }); - } - - /** + const existingPoliciesQuery = await tx.policy.findMany({ + where: { + organizationId: organizationId, + policyTemplateId: { in: policyTemplateIds }, + }, + select: { policyTemplateId: true }, + }); + const existingPolicyTemplateIdsSet = new Set( + existingPoliciesQuery + .map((p) => p.policyTemplateId) + .filter((id) => id !== null) as string[], + ); + + const policyTemplatesForCreation = policyTemplates.filter( + (template) => !existingPolicyTemplateIdsSet.has(template.id), + ); + + if (policyTemplatesForCreation.length > 0) { + await tx.policy.createMany({ + data: policyTemplatesForCreation.map((policyTemplate) => ({ + name: policyTemplate.name, + description: policyTemplate.description, + department: policyTemplate.department, + frequency: policyTemplate.frequency, + content: policyTemplate.content as Prisma.PolicyCreateInput["content"], + organizationId: organizationId, + policyTemplateId: policyTemplate.id, + })), + }); + } + + /** |-------------------------------------------------- | Upsert Task Instances |-------------------------------------------------- */ - const existingTasksQuery = await tx.task.findMany({ - where: { - organizationId: organizationId, - taskTemplateId: { in: taskTemplateIds }, - }, - select: { taskTemplateId: true }, - }); - const existingTaskTemplateIdsSet = new Set( - existingTasksQuery - .map((t) => t.taskTemplateId) - .filter((id) => id !== null) as string[], - ); - - const taskTemplatesForCreation = taskTemplates.filter( - (template) => !existingTaskTemplateIdsSet.has(template.id), - ); - if (taskTemplatesForCreation.length > 0) { - await tx.task.createMany({ - data: taskTemplatesForCreation.map((taskTemplate) => ({ - title: taskTemplate.name, - description: taskTemplate.description, - organizationId: organizationId, - taskTemplateId: taskTemplate.id, - })), - }); - } - - /** + const existingTasksQuery = await tx.task.findMany({ + where: { + organizationId: organizationId, + taskTemplateId: { in: taskTemplateIds }, + }, + select: { taskTemplateId: true }, + }); + const existingTaskTemplateIdsSet = new Set( + existingTasksQuery + .map((t) => t.taskTemplateId) + .filter((id) => id !== null) as string[], + ); + + const taskTemplatesForCreation = taskTemplates.filter( + (template) => !existingTaskTemplateIdsSet.has(template.id), + ); + if (taskTemplatesForCreation.length > 0) { + await tx.task.createMany({ + data: taskTemplatesForCreation.map((taskTemplate) => ({ + title: taskTemplate.name, + description: taskTemplate.description, + organizationId: organizationId, + taskTemplateId: taskTemplate.id, + })), + }); + } + + /** |-------------------------------------------------- | Establish Relations |-------------------------------------------------- @@ -266,183 +261,183 @@ export const _upsertOrgFrameworkStructureCore = async ({ | Connect Policies and Tasks to their respective Control instances. |-------------------------------------------------- */ - const allRelevantControls = await tx.control.findMany({ - where: { - organizationId: organizationId, - controlTemplateId: { in: controlTemplateIds }, - }, - select: { id: true, controlTemplateId: true }, - }); - const allRelevantPolicies = await tx.policy.findMany({ - where: { - organizationId: organizationId, - policyTemplateId: { in: policyTemplateIds }, - }, - select: { id: true, policyTemplateId: true }, - }); - const allRelevantTasks = await tx.task.findMany({ - where: { - organizationId: organizationId, - taskTemplateId: { in: taskTemplateIds }, - }, - select: { id: true, taskTemplateId: true }, - }); - - const controlTemplateIdToInstanceIdMap = new Map( - allRelevantControls - .filter((c) => c.controlTemplateId != null) - .map((c) => [c.controlTemplateId!, c.id]), - ); - const policyTemplateIdToInstanceIdMap = new Map( - allRelevantPolicies - .filter((p) => p.policyTemplateId != null) - .map((p) => [p.policyTemplateId!, p.id]), - ); - const taskTemplateIdToInstanceIdMap = new Map( - allRelevantTasks - .filter((t) => t.taskTemplateId != null) - .map((t) => [t.taskTemplateId!, t.id]), - ); - - const requirementMapEntriesToCreate: Prisma.RequirementMapCreateManyInput[] = - []; - - for (const controlTemplateRelation of groupedControlTemplateRelations) { - const newControlId = controlTemplateIdToInstanceIdMap.get( - controlTemplateRelation.controlTemplateId, - ); - - if (!newControlId) { - console.warn( - `UpsertOrgFrameworkStructureCore: Control instance not found for template ID ${controlTemplateRelation.controlTemplateId}. Skipping relation processing.`, - ); - continue; - } - - const updateData: Prisma.ControlUpdateInput = {}; - let needsUpdate = false; - - // --- Process Requirements for RequirementMap --- - if (controlTemplateRelation.requirementTemplateIds.length > 0) { - for (const reqTemplateId of controlTemplateRelation.requirementTemplateIds) { - let frameworkEditorFrameworkIdForReq: string | undefined; - for (const fw of frameworkEditorFrameworks) { - if (fw.requirements.some((r) => r.id === reqTemplateId)) { - frameworkEditorFrameworkIdForReq = fw.id; - break; - } - } - const frameworkInstanceId = frameworkEditorFrameworkIdForReq - ? editorFrameworkIdToInstanceIdMap.get( - frameworkEditorFrameworkIdForReq, - ) - : undefined; - - if (frameworkInstanceId) { - requirementMapEntriesToCreate.push({ - controlId: newControlId, - requirementId: reqTemplateId, - frameworkInstanceId: frameworkInstanceId, - }); - } else { - console.warn( - `UpsertOrgFrameworkStructureCore: Could not find FrameworkInstanceId for editor requirement ID ${reqTemplateId}. Cannot create RequirementMap for Control ${newControlId}.`, - ); - } - } - } - - // --- Connect Policies --- - if (controlTemplateRelation.policyTemplateIds.length > 0) { - const policiesToConnect = []; - for (const policyTemplateId of controlTemplateRelation.policyTemplateIds) { - const newPolicyId = - policyTemplateIdToInstanceIdMap.get(policyTemplateId); - if (newPolicyId) { - policiesToConnect.push({ id: newPolicyId }); - } else { - console.warn( - `UpsertOrgFrameworkStructureCore: Policy instance not found for template ID ${policyTemplateId}. Cannot connect to Control ${newControlId}.`, - ); - } - } - if (policiesToConnect.length > 0) { - updateData.policies = { connect: policiesToConnect }; - needsUpdate = true; - } - } - - // --- Connect Tasks --- - if (controlTemplateRelation.taskTemplateIds.length > 0) { - const tasksToConnect = []; - for (const taskTemplateId of controlTemplateRelation.taskTemplateIds) { - const newTaskId = - taskTemplateIdToInstanceIdMap.get(taskTemplateId); - if (newTaskId) { - tasksToConnect.push({ id: newTaskId }); - } else { - console.warn( - `UpsertOrgFrameworkStructureCore: Task instance not found for template ID ${taskTemplateId}. Cannot connect to Control ${newControlId}.`, - ); - } - } - if (tasksToConnect.length > 0) { - updateData.tasks = { connect: tasksToConnect }; - needsUpdate = true; - } - } - - if (needsUpdate) { - await tx.control.update({ - where: { id: newControlId }, - data: updateData, - }); - } - } - - // --- Create RequirementMap entries --- - if (requirementMapEntriesToCreate.length > 0) { - await tx.requirementMap.createMany({ - data: requirementMapEntriesToCreate, - skipDuplicates: true, - }); - } - - return { - processedFrameworks: frameworkEditorFrameworks, - controlTemplates, - policyTemplates, - taskTemplates, - }; + const allRelevantControls = await tx.control.findMany({ + where: { + organizationId: organizationId, + controlTemplateId: { in: controlTemplateIds }, + }, + select: { id: true, controlTemplateId: true }, + }); + const allRelevantPolicies = await tx.policy.findMany({ + where: { + organizationId: organizationId, + policyTemplateId: { in: policyTemplateIds }, + }, + select: { id: true, policyTemplateId: true }, + }); + const allRelevantTasks = await tx.task.findMany({ + where: { + organizationId: organizationId, + taskTemplateId: { in: taskTemplateIds }, + }, + select: { id: true, taskTemplateId: true }, + }); + + const controlTemplateIdToInstanceIdMap = new Map( + allRelevantControls + .filter((c) => c.controlTemplateId != null) + .map((c) => [c.controlTemplateId!, c.id]), + ); + const policyTemplateIdToInstanceIdMap = new Map( + allRelevantPolicies + .filter((p) => p.policyTemplateId != null) + .map((p) => [p.policyTemplateId!, p.id]), + ); + const taskTemplateIdToInstanceIdMap = new Map( + allRelevantTasks + .filter((t) => t.taskTemplateId != null) + .map((t) => [t.taskTemplateId!, t.id]), + ); + + const requirementMapEntriesToCreate: Prisma.RequirementMapCreateManyInput[] = + []; + + for (const controlTemplateRelation of groupedControlTemplateRelations) { + const newControlId = controlTemplateIdToInstanceIdMap.get( + controlTemplateRelation.controlTemplateId, + ); + + if (!newControlId) { + console.warn( + `UpsertOrgFrameworkStructureCore: Control instance not found for template ID ${controlTemplateRelation.controlTemplateId}. Skipping relation processing.`, + ); + continue; + } + + const updateData: Prisma.ControlUpdateInput = {}; + let needsUpdate = false; + + // --- Process Requirements for RequirementMap --- + if (controlTemplateRelation.requirementTemplateIds.length > 0) { + for (const reqTemplateId of controlTemplateRelation.requirementTemplateIds) { + let frameworkEditorFrameworkIdForReq: string | undefined; + for (const fw of frameworkEditorFrameworks) { + if (fw.requirements.some((r) => r.id === reqTemplateId)) { + frameworkEditorFrameworkIdForReq = fw.id; + break; + } + } + const frameworkInstanceId = frameworkEditorFrameworkIdForReq + ? editorFrameworkIdToInstanceIdMap.get( + frameworkEditorFrameworkIdForReq, + ) + : undefined; + + if (frameworkInstanceId) { + requirementMapEntriesToCreate.push({ + controlId: newControlId, + requirementId: reqTemplateId, + frameworkInstanceId: frameworkInstanceId, + }); + } else { + console.warn( + `UpsertOrgFrameworkStructureCore: Could not find FrameworkInstanceId for editor requirement ID ${reqTemplateId}. Cannot create RequirementMap for Control ${newControlId}.`, + ); + } + } + } + + // --- Connect Policies --- + if (controlTemplateRelation.policyTemplateIds.length > 0) { + const policiesToConnect = []; + for (const policyTemplateId of controlTemplateRelation.policyTemplateIds) { + const newPolicyId = + policyTemplateIdToInstanceIdMap.get(policyTemplateId); + if (newPolicyId) { + policiesToConnect.push({ id: newPolicyId }); + } else { + console.warn( + `UpsertOrgFrameworkStructureCore: Policy instance not found for template ID ${policyTemplateId}. Cannot connect to Control ${newControlId}.`, + ); + } + } + if (policiesToConnect.length > 0) { + updateData.policies = { connect: policiesToConnect }; + needsUpdate = true; + } + } + + // --- Connect Tasks --- + if (controlTemplateRelation.taskTemplateIds.length > 0) { + const tasksToConnect = []; + for (const taskTemplateId of controlTemplateRelation.taskTemplateIds) { + const newTaskId = taskTemplateIdToInstanceIdMap.get(taskTemplateId); + if (newTaskId) { + tasksToConnect.push({ id: newTaskId }); + } else { + console.warn( + `UpsertOrgFrameworkStructureCore: Task instance not found for template ID ${taskTemplateId}. Cannot connect to Control ${newControlId}.`, + ); + } + } + if (tasksToConnect.length > 0) { + updateData.tasks = { connect: tasksToConnect }; + needsUpdate = true; + } + } + + if (needsUpdate) { + await tx.control.update({ + where: { id: newControlId }, + data: updateData, + }); + } + } + + // --- Create RequirementMap entries --- + if (requirementMapEntriesToCreate.length > 0) { + await tx.requirementMap.createMany({ + data: requirementMapEntriesToCreate, + skipDuplicates: true, + }); + } + + return { + processedFrameworks: frameworkEditorFrameworks, + controlTemplates, + policyTemplates, + taskTemplates, + }; }; export const initializeOrganization = async ({ - frameworkIds, - organizationId, + frameworkIds, + organizationId, }: InitializeOrganizationInput) => { - const frameworksAndReqsToProcess = - await db.frameworkEditorFramework.findMany({ - where: { - id: { in: frameworkIds }, - }, - include: { - requirements: true, - }, - }); - - if (frameworksAndReqsToProcess.length === 0 && frameworkIds.length > 0) { - console.warn( - `InitializeOrganization: No FrameworkEditorFrameworks found for IDs: ${frameworkIds.join(", ")}`, - ); - } - - const result = await db.$transaction(async (tx) => { - return _upsertOrgFrameworkStructureCore({ - organizationId, - targetFrameworkEditorIds: frameworkIds, - frameworkEditorFrameworks: frameworksAndReqsToProcess, - tx, - }); - }); - return result; + const frameworksAndReqsToProcess = await db.frameworkEditorFramework.findMany( + { + where: { + id: { in: frameworkIds }, + }, + include: { + requirements: true, + }, + }, + ); + + if (frameworksAndReqsToProcess.length === 0 && frameworkIds.length > 0) { + console.warn( + `InitializeOrganization: No FrameworkEditorFrameworks found for IDs: ${frameworkIds.join(", ")}`, + ); + } + + const result = await db.$transaction(async (tx) => { + return _upsertOrgFrameworkStructureCore({ + organizationId, + targetFrameworkEditorIds: frameworkIds, + frameworkEditorFrameworks: frameworksAndReqsToProcess, + tx, + }); + }); + return result; }; diff --git a/apps/app/src/actions/organization/lib/stripe.ts b/apps/app/src/actions/organization/lib/stripe.ts index 53ac87fbc4..861794e262 100644 --- a/apps/app/src/actions/organization/lib/stripe.ts +++ b/apps/app/src/actions/organization/lib/stripe.ts @@ -3,12 +3,12 @@ import Stripe from "stripe"; export const stripeWebhookSecret = env.STRIPE_WEBHOOK_SECRET; -let stripe: Stripe | undefined; +let stripe: Stripe; if (env.STRIPE_SECRET_KEY && env.STRIPE_WEBHOOK_SECRET) { - stripe = new Stripe(env.STRIPE_SECRET_KEY, { - apiVersion: "2025-05-28.basil", - }); + stripe = new Stripe(env.STRIPE_SECRET_KEY, { + apiVersion: "2025-05-28.basil", + }); } export { stripe }; diff --git a/apps/app/src/actions/organization/remove-employee.ts b/apps/app/src/actions/organization/remove-employee.ts index 9df6233e7d..5125e458f3 100644 --- a/apps/app/src/actions/organization/remove-employee.ts +++ b/apps/app/src/actions/organization/remove-employee.ts @@ -7,141 +7,142 @@ import { authActionClient } from "../safe-action"; import type { ActionResponse } from "../types"; const removeEmployeeSchema = z.object({ - memberId: z.string(), + memberId: z.string(), }); export const removeEmployeeRoleOrMember = authActionClient - .metadata({ - name: "remove-employee-role-or-member", - track: { - event: "remove_employee", // Changed event name - channel: "organization", - }, - }) - .schema(removeEmployeeSchema) - .action( - async ({ - parsedInput, - ctx, - }): Promise< - ActionResponse<{ removed: boolean; roleUpdated?: boolean }> - > => { - const organizationId = ctx.session.activeOrganizationId; - const currentUserId = ctx.user.id; - - if (!organizationId) { - return { - success: false, - error: "Organization not found", - }; - } - - const { memberId } = parsedInput; - - try { - // 1. Permission Check: Ensure current user is admin or owner - const currentUserMember = await db.member.findFirst({ - where: { - organizationId: organizationId, - userId: currentUserId, - }, - }); - - if ( - !currentUserMember || - !["admin", "owner"].includes(currentUserMember.role) - ) { - return { - success: false, - error: "Permission denied: Only admins or owners can remove employees.", - }; - } - - // 2. Fetch Target Member - const targetMember = await db.member.findFirst({ - where: { - id: memberId, - organizationId: organizationId, - }, - }); - - if (!targetMember) { - return { - success: false, - error: "Target employee not found in this organization.", - }; - } - - // 3. Check if target has 'employee' role - const roles = targetMember.role.split(",").filter(Boolean); // Handle empty strings/commas - if (!roles.includes("employee")) { - return { - success: false, - error: "Target member does not have the employee role.", - }; - } - - // 4. Logic: Remove role or delete member - if (roles.length === 1 && roles[0] === "employee") { - // Only has employee role - delete member fully - - // Cannot remove owner (shouldn't happen if only role is employee, but safety check) - if (targetMember.role === "owner") { - return { - success: false, - error: "Cannot remove the organization owner.", - }; - } - // Cannot remove self - if (targetMember.userId === currentUserId) { - return { - success: false, - error: "You cannot remove yourself.", - }; - } - - await db.$transaction([ - db.member.delete({ where: { id: memberId } }), - db.session.deleteMany({ - where: { userId: targetMember.userId }, - }), - ]); - - // Revalidate - revalidatePath(`/${organizationId}/people/all`); - revalidateTag(`user_${currentUserId}`); - - return { success: true, data: { removed: true } }; - } else { - // Has other roles - just remove 'employee' role - const updatedRoles = roles - .filter((role) => role !== "employee") - .join(","); - - await db.member.update({ - where: { id: memberId }, - data: { role: updatedRoles }, - }); - - // Revalidate - revalidatePath(`/${organizationId}/people/all`); - revalidateTag(`user_${currentUserId}`); - - return { - success: true, - data: { removed: false, roleUpdated: true }, - }; - } - } catch (error) { - console.error("Error removing employee role/member:", error); - const errorMessage = - error instanceof Error - ? error.message - : "Failed to remove employee role or member."; - return { - success: false, - error: errorMessage, - }; - } - }, - ); + .metadata({ + name: "remove-employee-role-or-member", + track: { + event: "remove_employee", // Changed event name + channel: "organization", + }, + }) + .schema(removeEmployeeSchema) + .action( + async ({ + parsedInput, + ctx, + }): Promise< + ActionResponse<{ removed: boolean; roleUpdated?: boolean }> + > => { + const organizationId = ctx.session.activeOrganizationId; + const currentUserId = ctx.user.id; + + if (!organizationId) { + return { + success: false, + error: "Organization not found", + }; + } + + const { memberId } = parsedInput; + + try { + // 1. Permission Check: Ensure current user is admin or owner + const currentUserMember = await db.member.findFirst({ + where: { + organizationId: organizationId, + userId: currentUserId, + }, + }); + + if ( + !currentUserMember || + !["admin", "owner"].includes(currentUserMember.role) + ) { + return { + success: false, + error: + "Permission denied: Only admins or owners can remove employees.", + }; + } + + // 2. Fetch Target Member + const targetMember = await db.member.findFirst({ + where: { + id: memberId, + organizationId: organizationId, + }, + }); + + if (!targetMember) { + return { + success: false, + error: "Target employee not found in this organization.", + }; + } + + // 3. Check if target has 'employee' role + const roles = targetMember.role.split(",").filter(Boolean); // Handle empty strings/commas + if (!roles.includes("employee")) { + return { + success: false, + error: "Target member does not have the employee role.", + }; + } + + // 4. Logic: Remove role or delete member + if (roles.length === 1 && roles[0] === "employee") { + // Only has employee role - delete member fully + + // Cannot remove owner (shouldn't happen if only role is employee, but safety check) + if (targetMember.role === "owner") { + return { + success: false, + error: "Cannot remove the organization owner.", + }; + } + // Cannot remove self + if (targetMember.userId === currentUserId) { + return { + success: false, + error: "You cannot remove yourself.", + }; + } + + await db.$transaction([ + db.member.delete({ where: { id: memberId } }), + db.session.deleteMany({ + where: { userId: targetMember.userId }, + }), + ]); + + // Revalidate + revalidatePath(`/${organizationId}/people/all`); + revalidateTag(`user_${currentUserId}`); + + return { success: true, data: { removed: true } }; + } else { + // Has other roles - just remove 'employee' role + const updatedRoles = roles + .filter((role) => role !== "employee") + .join(","); + + await db.member.update({ + where: { id: memberId }, + data: { role: updatedRoles }, + }); + + // Revalidate + revalidatePath(`/${organizationId}/people/all`); + revalidateTag(`user_${currentUserId}`); + + return { + success: true, + data: { removed: false, roleUpdated: true }, + }; + } + } catch (error) { + console.error("Error removing employee role/member:", error); + const errorMessage = + error instanceof Error + ? error.message + : "Failed to remove employee role or member."; + return { + success: false, + error: errorMessage, + }; + } + }, + ); diff --git a/apps/app/src/actions/organization/revoke-api-key-action.ts b/apps/app/src/actions/organization/revoke-api-key-action.ts index 75aa0e5210..8e17850012 100644 --- a/apps/app/src/actions/organization/revoke-api-key-action.ts +++ b/apps/app/src/actions/organization/revoke-api-key-action.ts @@ -6,52 +6,50 @@ import { revalidatePath } from "next/cache"; import { z } from "zod"; const revokeApiKeySchema = z.object({ - id: z.string().min(1), + id: z.string().min(1), }); export const revokeApiKeyAction = authActionClient - .schema(revokeApiKeySchema) - .metadata({ - name: "revokeApiKey", - track: { - event: "revokeApiKey", - channel: "server", - }, - }) - .action(async ({ parsedInput, ctx }) => { - try { - const { id } = parsedInput; + .schema(revokeApiKeySchema) + .metadata({ + name: "revokeApiKey", + track: { + event: "revokeApiKey", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + try { + const { id } = parsedInput; - const result = await db.apiKey.updateMany({ - where: { - id, - organizationId: ctx.session.activeOrganizationId!, - }, - data: { - isActive: false, - }, - }); + const result = await db.apiKey.updateMany({ + where: { + id, + organizationId: ctx.session.activeOrganizationId!, + }, + data: { + isActive: false, + }, + }); - if (result.count === 0) { - return { - success: false, - error: "API key not found or not authorized to revoke", - }; - } + if (result.count === 0) { + return { + success: false, + error: "API key not found or not authorized to revoke", + }; + } - revalidatePath( - `/${ctx.session.activeOrganizationId}/settings/api-keys`, - ); + revalidatePath(`/${ctx.session.activeOrganizationId}/settings/api-keys`); - return { - success: true, - message: "API key revoked successfully", - }; - } catch (error) { - console.error("Error revoking API key:", error); - return { - success: false, - error: "An error occurred while revoking the API key", - }; - } - }); + return { + success: true, + message: "API key revoked successfully", + }; + } catch (error) { + console.error("Error revoking API key:", error); + return { + success: false, + error: "An error occurred while revoking the API key", + }; + } + }); diff --git a/apps/app/src/actions/organization/update-organization-name-action.ts b/apps/app/src/actions/organization/update-organization-name-action.ts index 081829a0b1..965b589935 100644 --- a/apps/app/src/actions/organization/update-organization-name-action.ts +++ b/apps/app/src/actions/organization/update-organization-name-action.ts @@ -8,42 +8,42 @@ import { authActionClient } from "../safe-action"; import { organizationNameSchema } from "../schema"; export const updateOrganizationNameAction = authActionClient - .schema(organizationNameSchema) - .metadata({ - name: "update-organization-name", - track: { - event: "update-organization-name", - channel: "server", - }, - }) - .action(async ({ parsedInput, ctx }) => { - const { name } = parsedInput; - const { activeOrganizationId } = ctx.session; - - if (!name) { - throw new Error("Invalid user input"); - } - - if (!activeOrganizationId) { - throw new Error("No active organization"); - } - - try { - await db.$transaction(async () => { - await db.organization.update({ - where: { id: activeOrganizationId ?? "" }, - data: { name }, - }); - }); - - revalidatePath("/settings"); - revalidateTag(`organization_${activeOrganizationId}`); - - return { - success: true, - }; - } catch (error) { - console.error(error); - throw new Error("Failed to update organization name"); - } - }); + .schema(organizationNameSchema) + .metadata({ + name: "update-organization-name", + track: { + event: "update-organization-name", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { name } = parsedInput; + const { activeOrganizationId } = ctx.session; + + if (!name) { + throw new Error("Invalid user input"); + } + + if (!activeOrganizationId) { + throw new Error("No active organization"); + } + + try { + await db.$transaction(async () => { + await db.organization.update({ + where: { id: activeOrganizationId ?? "" }, + data: { name }, + }); + }); + + revalidatePath("/settings"); + revalidateTag(`organization_${activeOrganizationId}`); + + return { + success: true, + }; + } catch (error) { + console.error(error); + throw new Error("Failed to update organization name"); + } + }); diff --git a/apps/app/src/actions/organization/update-organization-website-action.ts b/apps/app/src/actions/organization/update-organization-website-action.ts index f4693622dd..5782a362bb 100644 --- a/apps/app/src/actions/organization/update-organization-website-action.ts +++ b/apps/app/src/actions/organization/update-organization-website-action.ts @@ -8,42 +8,42 @@ import { authActionClient } from "../safe-action"; import { organizationWebsiteSchema } from "../schema"; export const updateOrganizationWebsiteAction = authActionClient - .schema(organizationWebsiteSchema) - .metadata({ - name: "update-organization-website", - track: { - event: "update-organization-website", - channel: "server", - }, - }) - .action(async ({ parsedInput, ctx }) => { - const { website } = parsedInput; - const { activeOrganizationId } = ctx.session; - - if (!website) { - throw new Error("Invalid user input"); - } - - if (!activeOrganizationId) { - throw new Error("No active organization"); - } - - try { - await db.$transaction(async () => { - await db.organization.update({ - where: { id: activeOrganizationId ?? "" }, - data: { website }, - }); - }); - - revalidatePath("/settings"); - revalidateTag(`organization_${activeOrganizationId}`); - - return { - success: true, - }; - } catch (error) { - console.error(error); - throw new Error("Failed to update organization website"); - } - }); + .schema(organizationWebsiteSchema) + .metadata({ + name: "update-organization-website", + track: { + event: "update-organization-website", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { website } = parsedInput; + const { activeOrganizationId } = ctx.session; + + if (!website) { + throw new Error("Invalid user input"); + } + + if (!activeOrganizationId) { + throw new Error("No active organization"); + } + + try { + await db.$transaction(async () => { + await db.organization.update({ + where: { id: activeOrganizationId ?? "" }, + data: { website }, + }); + }); + + revalidatePath("/settings"); + revalidateTag(`organization_${activeOrganizationId}`); + + return { + success: true, + }; + } catch (error) { + console.error(error); + throw new Error("Failed to update organization website"); + } + }); diff --git a/apps/app/src/actions/people/create-employee-action.ts b/apps/app/src/actions/people/create-employee-action.ts index cdefe77b4e..9bfd311a3c 100644 --- a/apps/app/src/actions/people/create-employee-action.ts +++ b/apps/app/src/actions/people/create-employee-action.ts @@ -7,57 +7,56 @@ import { createEmployeeSchema } from "../schema"; import type { ActionResponse } from "../types"; export const createEmployeeAction = authActionClient - .schema(createEmployeeSchema) - .metadata({ - name: "create-employee", - track: { - event: "create-employee", - channel: "server", - }, - }) - .action(async ({ parsedInput, ctx }): Promise => { - const { name, email, department, externalEmployeeId } = parsedInput; - const { user, session } = ctx; + .schema(createEmployeeSchema) + .metadata({ + name: "create-employee", + track: { + event: "create-employee", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }): Promise => { + const { name, email, department, externalEmployeeId } = parsedInput; + const { user, session } = ctx; - if (!session.activeOrganizationId) { - return { - success: false, - error: "Not authorized - no organization found", - }; - } + if (!session.activeOrganizationId) { + return { + success: false, + error: "Not authorized - no organization found", + }; + } - try { - const employee = await completeEmployeeCreation({ - name, - email, - department, - organizationId: session.activeOrganizationId, - externalEmployeeId, - }); + try { + const employee = await completeEmployeeCreation({ + name, + email, + department, + organizationId: session.activeOrganizationId, + externalEmployeeId, + }); - return { - success: true, - data: employee, - }; - } catch (error) { - console.error("Error creating employee:", error); + return { + success: true, + data: employee, + }; + } catch (error) { + console.error("Error creating employee:", error); - if ( - error instanceof PrismaClientKnownRequestError && - error.code === "P2002" - ) { - return { - success: false, - error: "An employee with this email already exists in your organization", - }; - } + if ( + error instanceof PrismaClientKnownRequestError && + error.code === "P2002" + ) { + return { + success: false, + error: + "An employee with this email already exists in your organization", + }; + } - return { - success: false, - error: - error instanceof Error - ? error.message - : "Failed to create employee", - }; - } - }); + return { + success: false, + error: + error instanceof Error ? error.message : "Failed to create employee", + }; + } + }); diff --git a/apps/app/src/actions/policies/accept-requested-policy-changes.ts b/apps/app/src/actions/policies/accept-requested-policy-changes.ts index 928476037e..2c5adb0b3e 100644 --- a/apps/app/src/actions/policies/accept-requested-policy-changes.ts +++ b/apps/app/src/actions/policies/accept-requested-policy-changes.ts @@ -7,96 +7,96 @@ import { authActionClient } from "../safe-action"; import { z } from "zod"; const acceptRequestedPolicyChangesSchema = z.object({ - id: z.string(), - approverId: z.string(), - comment: z.string().optional(), - entityId: z.string(), + id: z.string(), + approverId: z.string(), + comment: z.string().optional(), + entityId: z.string(), }); export const acceptRequestedPolicyChangesAction = authActionClient - .schema(acceptRequestedPolicyChangesSchema) - .metadata({ - name: "accept-requested-policy-changes", - track: { - event: "accept-requested-policy-changes", - description: "Accept Policy Changes", - channel: "server", - }, - }) - .action(async ({ parsedInput, ctx }) => { - const { id, approverId, comment } = parsedInput; - const { user, session } = ctx; + .schema(acceptRequestedPolicyChangesSchema) + .metadata({ + name: "accept-requested-policy-changes", + track: { + event: "accept-requested-policy-changes", + description: "Accept Policy Changes", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { id, approverId, comment } = parsedInput; + const { user, session } = ctx; - if (!user.id || !session.activeOrganizationId) { - throw new Error("Unauthorized"); - } + if (!user.id || !session.activeOrganizationId) { + throw new Error("Unauthorized"); + } - if (!approverId) { - throw new Error("Approver is required"); - } + if (!approverId) { + throw new Error("Approver is required"); + } - try { - const policy = await db.policy.findUnique({ - where: { - id, - organizationId: session.activeOrganizationId, - }, - }); + try { + const policy = await db.policy.findUnique({ + where: { + id, + organizationId: session.activeOrganizationId, + }, + }); - if (!policy) { - throw new Error("Policy not found"); - } + if (!policy) { + throw new Error("Policy not found"); + } - if (policy.approverId !== approverId) { - throw new Error("Approver is not the same"); - } + if (policy.approverId !== approverId) { + throw new Error("Approver is not the same"); + } - // Update policy status - await db.policy.update({ - where: { - id, - organizationId: session.activeOrganizationId, - }, - data: { - status: PolicyStatus.published, - approverId: null, - }, - }); + // Update policy status + await db.policy.update({ + where: { + id, + organizationId: session.activeOrganizationId, + }, + data: { + status: PolicyStatus.published, + approverId: null, + }, + }); - // If a comment was provided, create a comment - if (comment && comment.trim() !== "") { - const member = await db.member.findFirst({ - where: { - userId: user.id, - organizationId: session.activeOrganizationId, - }, - }); + // If a comment was provided, create a comment + if (comment && comment.trim() !== "") { + const member = await db.member.findFirst({ + where: { + userId: user.id, + organizationId: session.activeOrganizationId, + }, + }); - if (member) { - await db.comment.create({ - data: { - content: `Policy changes accepted: ${comment}`, - entityId: id, - entityType: "policy", - organizationId: session.activeOrganizationId, - authorId: member.id, - }, - }); - } - } + if (member) { + await db.comment.create({ + data: { + content: `Policy changes accepted: ${comment}`, + entityId: id, + entityType: "policy", + organizationId: session.activeOrganizationId, + authorId: member.id, + }, + }); + } + } - revalidatePath(`/${session.activeOrganizationId}/policies`); - revalidatePath(`/${session.activeOrganizationId}/policies/${id}`); - revalidateTag("policies"); + revalidatePath(`/${session.activeOrganizationId}/policies`); + revalidatePath(`/${session.activeOrganizationId}/policies/${id}`); + revalidateTag("policies"); - return { - success: true, - }; - } catch (error) { - console.error("Error submitting policy for approval:", error); + return { + success: true, + }; + } catch (error) { + console.error("Error submitting policy for approval:", error); - return { - success: false, - }; - } - }); + return { + success: false, + }; + } + }); diff --git a/apps/app/src/actions/policies/archive-policy.ts b/apps/app/src/actions/policies/archive-policy.ts index 8dc90c81cf..19e81e03ce 100644 --- a/apps/app/src/actions/policies/archive-policy.ts +++ b/apps/app/src/actions/policies/archive-policy.ts @@ -6,73 +6,72 @@ import { z } from "zod"; import { authActionClient } from "../safe-action"; const archivePolicySchema = z.object({ - id: z.string(), - action: z.enum(["archive", "restore"]).optional(), - entityId: z.string(), + id: z.string(), + action: z.enum(["archive", "restore"]).optional(), + entityId: z.string(), }); export const archivePolicyAction = authActionClient - .schema(archivePolicySchema) - .metadata({ - name: "archive-policy", - track: { - event: "archive-policy", - description: "Archive Policy", - channel: "server", - }, - }) - .action(async ({ parsedInput, ctx }) => { - const { id, action } = parsedInput; - const { activeOrganizationId } = ctx.session; + .schema(archivePolicySchema) + .metadata({ + name: "archive-policy", + track: { + event: "archive-policy", + description: "Archive Policy", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { id, action } = parsedInput; + const { activeOrganizationId } = ctx.session; - if (!activeOrganizationId) { - return { - success: false, - error: "Not authorized", - }; - } + if (!activeOrganizationId) { + return { + success: false, + error: "Not authorized", + }; + } - try { - const policy = await db.policy.findUnique({ - where: { - id, - organizationId: activeOrganizationId, - }, - }); + try { + const policy = await db.policy.findUnique({ + where: { + id, + organizationId: activeOrganizationId, + }, + }); - if (!policy) { - return { - success: false, - error: "Policy not found", - }; - } + if (!policy) { + return { + success: false, + error: "Policy not found", + }; + } - // Determine if we should archive or restore based on action or current state - const shouldArchive = - action === "archive" || - (action === undefined && !policy.isArchived); + // Determine if we should archive or restore based on action or current state + const shouldArchive = + action === "archive" || (action === undefined && !policy.isArchived); - await db.policy.update({ - where: { id }, - data: { - isArchived: shouldArchive, - }, - }); + await db.policy.update({ + where: { id }, + data: { + isArchived: shouldArchive, + }, + }); - revalidatePath(`/${activeOrganizationId}/policies/${id}`); - revalidatePath(`/${activeOrganizationId}/policies/all`); - revalidatePath(`/${activeOrganizationId}/policies`); - revalidateTag("policies"); + revalidatePath(`/${activeOrganizationId}/policies/${id}`); + revalidatePath(`/${activeOrganizationId}/policies/all`); + revalidatePath(`/${activeOrganizationId}/policies`); + revalidateTag("policies"); - return { - success: true, - isArchived: shouldArchive, - }; - } catch (error) { - console.error(error); - return { - success: false, - error: "Failed to update policy archive status", - }; - } - }); + return { + success: true, + isArchived: shouldArchive, + }; + } catch (error) { + console.error(error); + return { + success: false, + error: "Failed to update policy archive status", + }; + } + }); diff --git a/apps/app/src/actions/policies/create-new-policy.ts b/apps/app/src/actions/policies/create-new-policy.ts index 238844bba2..57f428ceef 100644 --- a/apps/app/src/actions/policies/create-new-policy.ts +++ b/apps/app/src/actions/policies/create-new-policy.ts @@ -7,117 +7,117 @@ import { authActionClient } from "../safe-action"; import { createPolicySchema } from "../schema"; export const createPolicyAction = authActionClient - .schema(createPolicySchema) - .metadata({ - name: "create-policy", - track: { - event: "create-policy", - description: "Create New Policy", - channel: "server", - }, - }) - .action(async ({ parsedInput, ctx }) => { - const { title, description, controlIds } = parsedInput; - const { activeOrganizationId } = ctx.session; - const { user } = ctx; + .schema(createPolicySchema) + .metadata({ + name: "create-policy", + track: { + event: "create-policy", + description: "Create New Policy", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { title, description, controlIds } = parsedInput; + const { activeOrganizationId } = ctx.session; + const { user } = ctx; - if (!activeOrganizationId) { - return { - success: false, - error: "Not authorized", - }; - } + if (!activeOrganizationId) { + return { + success: false, + error: "Not authorized", + }; + } - if (!user) { - return { - success: false, - error: "Not authorized", - }; - } + if (!user) { + return { + success: false, + error: "Not authorized", + }; + } - // Find member id in the organization - const member = await db.member.findFirst({ - where: { - userId: user.id, - organizationId: activeOrganizationId, - }, - }); + // Find member id in the organization + const member = await db.member.findFirst({ + where: { + userId: user.id, + organizationId: activeOrganizationId, + }, + }); - if (!member) { - return { - success: false, - error: "Not authorized", - }; - } + if (!member) { + return { + success: false, + error: "Not authorized", + }; + } - try { - // Create the policy - const policy = await db.policy.create({ - data: { - name: title, - description, - organizationId: activeOrganizationId, - assigneeId: member.id, - department: Departments.none, - frequency: Frequency.monthly, - content: [ - { - type: "paragraph", - content: [{ type: "text", text: "" }], - }, - ], - ...(controlIds && - controlIds.length > 0 && { - controls: { - connect: controlIds.map((id) => ({ id })), - }, - }), - }, - }); + try { + // Create the policy + const policy = await db.policy.create({ + data: { + name: title, + description, + organizationId: activeOrganizationId, + assigneeId: member.id, + department: Departments.none, + frequency: Frequency.monthly, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "" }], + }, + ], + ...(controlIds && + controlIds.length > 0 && { + controls: { + connect: controlIds.map((id) => ({ id })), + }, + }), + }, + }); - // Create artifacts for each control - // if (controlIds && controlIds.length > 0) { - // // Create artifacts that link the policy to controls - // await Promise.all( - // controlIds.map(async (controlId) => { - // // Create the artifact - // const artifact = await db.artifact.create({ - // data: { - // type: "policy", - // policyId: policy.id, - // organizationId: activeOrganizationId, - // }, - // }); + // Create artifacts for each control + // if (controlIds && controlIds.length > 0) { + // // Create artifacts that link the policy to controls + // await Promise.all( + // controlIds.map(async (controlId) => { + // // Create the artifact + // const artifact = await db.artifact.create({ + // data: { + // type: "policy", + // policyId: policy.id, + // organizationId: activeOrganizationId, + // }, + // }); - // // Connect the artifact to the control - // await db.control.update({ - // where: { id: controlId }, - // data: { - // artifacts: { - // connect: { id: artifact.id }, - // }, - // }, - // }); + // // Connect the artifact to the control + // await db.control.update({ + // where: { id: controlId }, + // data: { + // artifacts: { + // connect: { id: artifact.id }, + // }, + // }, + // }); - // return artifact; - // }), - // ); - // } + // return artifact; + // }), + // ); + // } - revalidatePath(`/${activeOrganizationId}/policies/all`); - revalidatePath(`/${activeOrganizationId}/policies`); - revalidateTag("policies"); + revalidatePath(`/${activeOrganizationId}/policies/all`); + revalidatePath(`/${activeOrganizationId}/policies`); + revalidateTag("policies"); - return { - success: true, - policyId: policy.id, - }; - } catch (error) { - console.error(error); + return { + success: true, + policyId: policy.id, + }; + } catch (error) { + console.error(error); - return { - success: false, - error: "Failed to create policy", - }; - } - }); + return { + success: false, + error: "Failed to create policy", + }; + } + }); diff --git a/apps/app/src/actions/policies/delete-policy.ts b/apps/app/src/actions/policies/delete-policy.ts index a27d7286f8..6eb474cd1c 100644 --- a/apps/app/src/actions/policies/delete-policy.ts +++ b/apps/app/src/actions/policies/delete-policy.ts @@ -6,64 +6,64 @@ import { z } from "zod"; import { authActionClient } from "../safe-action"; const deletePolicySchema = z.object({ - id: z.string(), - entityId: z.string(), + id: z.string(), + entityId: z.string(), }); export const deletePolicyAction = authActionClient - .schema(deletePolicySchema) - .metadata({ - name: "delete-policy", - track: { - event: "delete-policy", - description: "Delete Policy", - channel: "server", - }, - }) - .action(async ({ parsedInput, ctx }) => { - const { id } = parsedInput; - const { activeOrganizationId } = ctx.session; + .schema(deletePolicySchema) + .metadata({ + name: "delete-policy", + track: { + event: "delete-policy", + description: "Delete Policy", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { id } = parsedInput; + const { activeOrganizationId } = ctx.session; - if (!activeOrganizationId) { - return { - success: false, - error: "Not authorized", - }; - } + if (!activeOrganizationId) { + return { + success: false, + error: "Not authorized", + }; + } - try { - const policy = await db.policy.findUnique({ - where: { - id, - organizationId: activeOrganizationId, - }, - }); + try { + const policy = await db.policy.findUnique({ + where: { + id, + organizationId: activeOrganizationId, + }, + }); - if (!policy) { - return { - success: false, - error: "Policy not found", - }; - } + if (!policy) { + return { + success: false, + error: "Policy not found", + }; + } - // Delete the policy - await db.policy.delete({ - where: { id }, - }); + // Delete the policy + await db.policy.delete({ + where: { id }, + }); - // Revalidate paths to update UI - revalidatePath(`/${activeOrganizationId}/policies/all`); - revalidatePath(`/${activeOrganizationId}/policies`); - revalidateTag("policies"); + // Revalidate paths to update UI + revalidatePath(`/${activeOrganizationId}/policies/all`); + revalidatePath(`/${activeOrganizationId}/policies`); + revalidateTag("policies"); - return { - success: true, - }; - } catch (error) { - console.error(error); - return { - success: false, - error: "Failed to delete policy", - }; - } - }); + return { + success: true, + }; + } catch (error) { + console.error(error); + return { + success: false, + error: "Failed to delete policy", + }; + } + }); diff --git a/apps/app/src/actions/policies/deny-requested-policy-changes.ts b/apps/app/src/actions/policies/deny-requested-policy-changes.ts index 28bab5dbed..db78a5903f 100644 --- a/apps/app/src/actions/policies/deny-requested-policy-changes.ts +++ b/apps/app/src/actions/policies/deny-requested-policy-changes.ts @@ -7,96 +7,96 @@ import { authActionClient } from "../safe-action"; import { z } from "zod"; const denyRequestedPolicyChangesSchema = z.object({ - id: z.string(), - approverId: z.string(), - comment: z.string().optional(), - entityId: z.string(), + id: z.string(), + approverId: z.string(), + comment: z.string().optional(), + entityId: z.string(), }); export const denyRequestedPolicyChangesAction = authActionClient - .schema(denyRequestedPolicyChangesSchema) - .metadata({ - name: "deny-requested-policy-changes", - track: { - event: "deny-requested-policy-changes", - description: "Deny Policy Changes", - channel: "server", - }, - }) - .action(async ({ parsedInput, ctx }) => { - const { id, approverId, comment } = parsedInput; - const { user, session } = ctx; + .schema(denyRequestedPolicyChangesSchema) + .metadata({ + name: "deny-requested-policy-changes", + track: { + event: "deny-requested-policy-changes", + description: "Deny Policy Changes", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { id, approverId, comment } = parsedInput; + const { user, session } = ctx; - if (!user.id || !session.activeOrganizationId) { - throw new Error("Unauthorized"); - } + if (!user.id || !session.activeOrganizationId) { + throw new Error("Unauthorized"); + } - if (!approverId) { - throw new Error("Approver is required"); - } + if (!approverId) { + throw new Error("Approver is required"); + } - try { - const policy = await db.policy.findUnique({ - where: { - id, - organizationId: session.activeOrganizationId, - }, - }); + try { + const policy = await db.policy.findUnique({ + where: { + id, + organizationId: session.activeOrganizationId, + }, + }); - if (!policy) { - throw new Error("Policy not found"); - } + if (!policy) { + throw new Error("Policy not found"); + } - if (policy.approverId !== approverId) { - throw new Error("Approver is not the same"); - } + if (policy.approverId !== approverId) { + throw new Error("Approver is not the same"); + } - // Update policy status - await db.policy.update({ - where: { - id, - organizationId: session.activeOrganizationId, - }, - data: { - status: PolicyStatus.draft, - approverId: null, - }, - }); + // Update policy status + await db.policy.update({ + where: { + id, + organizationId: session.activeOrganizationId, + }, + data: { + status: PolicyStatus.draft, + approverId: null, + }, + }); - // If a comment was provided, create a comment - if (comment && comment.trim() !== "") { - const member = await db.member.findFirst({ - where: { - userId: user.id, - organizationId: session.activeOrganizationId, - }, - }); + // If a comment was provided, create a comment + if (comment && comment.trim() !== "") { + const member = await db.member.findFirst({ + where: { + userId: user.id, + organizationId: session.activeOrganizationId, + }, + }); - if (member) { - await db.comment.create({ - data: { - content: `Policy changes denied: ${comment}`, - entityId: id, - entityType: "policy", - organizationId: session.activeOrganizationId, - authorId: member.id, - }, - }); - } - } + if (member) { + await db.comment.create({ + data: { + content: `Policy changes denied: ${comment}`, + entityId: id, + entityType: "policy", + organizationId: session.activeOrganizationId, + authorId: member.id, + }, + }); + } + } - revalidatePath(`/${session.activeOrganizationId}/policies`); - revalidatePath(`/${session.activeOrganizationId}/policies/${id}`); - revalidateTag("policies"); + revalidatePath(`/${session.activeOrganizationId}/policies`); + revalidatePath(`/${session.activeOrganizationId}/policies/${id}`); + revalidateTag("policies"); - return { - success: true, - }; - } catch (error) { - console.error("Error submitting policy for approval:", error); + return { + success: true, + }; + } catch (error) { + console.error("Error submitting policy for approval:", error); - return { - success: false, - }; - } - }); + return { + success: false, + }; + } + }); diff --git a/apps/app/src/actions/policies/submit-policy-for-approval-action.ts b/apps/app/src/actions/policies/submit-policy-for-approval-action.ts index bef6d4a35c..aba14c5b20 100644 --- a/apps/app/src/actions/policies/submit-policy-for-approval-action.ts +++ b/apps/app/src/actions/policies/submit-policy-for-approval-action.ts @@ -7,64 +7,64 @@ import { authActionClient } from "../safe-action"; import { updatePolicyFormSchema } from "../schema"; export const submitPolicyForApprovalAction = authActionClient - .schema(updatePolicyFormSchema) - .metadata({ - name: "submit-policy-for-approval", - track: { - event: "submit-policy-for-approval", - description: "Submit Policy for Approval", - channel: "server", - }, - }) - .action(async ({ parsedInput, ctx }) => { - const { - id, - assigneeId, - department, - review_frequency, - review_date, - isRequiredToSign, - approverId, - } = parsedInput; - const { user, session } = ctx; + .schema(updatePolicyFormSchema) + .metadata({ + name: "submit-policy-for-approval", + track: { + event: "submit-policy-for-approval", + description: "Submit Policy for Approval", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { + id, + assigneeId, + department, + review_frequency, + review_date, + isRequiredToSign, + approverId, + } = parsedInput; + const { user, session } = ctx; - if (!user.id || !session.activeOrganizationId) { - throw new Error("Unauthorized"); - } + if (!user.id || !session.activeOrganizationId) { + throw new Error("Unauthorized"); + } - if (!approverId) { - throw new Error("Approver is required"); - } + if (!approverId) { + throw new Error("Approver is required"); + } - try { - const newReviewDate = review_date; + try { + const newReviewDate = review_date; - await db.policy.update({ - where: { - id, - organizationId: session.activeOrganizationId, - }, - data: { - status: PolicyStatus.needs_review, - assigneeId, - department, - frequency: review_frequency, - reviewDate: newReviewDate, - isRequiredToSign: isRequiredToSign === "required", - approverId, - }, - }); + await db.policy.update({ + where: { + id, + organizationId: session.activeOrganizationId, + }, + data: { + status: PolicyStatus.needs_review, + assigneeId, + department, + frequency: review_frequency, + reviewDate: newReviewDate, + isRequiredToSign: isRequiredToSign === "required", + approverId, + }, + }); - revalidatePath(`/${session.activeOrganizationId}/policies/${id}`); + revalidatePath(`/${session.activeOrganizationId}/policies/${id}`); - return { - success: true, - }; - } catch (error) { - console.error("Error submitting policy for approval:", error); + return { + success: true, + }; + } catch (error) { + console.error("Error submitting policy for approval:", error); - return { - success: false, - }; - } - }); + return { + success: false, + }; + } + }); diff --git a/apps/app/src/actions/policies/update-policy-action.ts b/apps/app/src/actions/policies/update-policy-action.ts index a801d2dd6e..faf26c0256 100644 --- a/apps/app/src/actions/policies/update-policy-action.ts +++ b/apps/app/src/actions/policies/update-policy-action.ts @@ -7,123 +7,120 @@ import { authActionClient } from "../safe-action"; import { updatePolicySchema } from "../schema"; interface ContentNode { - type: string; - content?: ContentNode[]; - text?: string; - attrs?: Record; - marks?: Array<{ type: string; attrs?: Record }>; - [key: string]: any; + type: string; + content?: ContentNode[]; + text?: string; + attrs?: Record; + marks?: Array<{ type: string; attrs?: Record }>; + [key: string]: any; } // Simplified content processor that creates a new plain object function processContent( - content: ContentNode | ContentNode[], + content: ContentNode | ContentNode[], ): ContentNode | ContentNode[] { - if (!content) return content; - - // Handle arrays - if (Array.isArray(content)) { - return content.map((node) => processContent(node) as ContentNode); - } - - // Create a new plain object with only the necessary properties - const processed: ContentNode = { - type: content.type, - }; - - if (content.text !== undefined) { - processed.text = content.text; - } - - if (content.attrs) { - processed.attrs = { ...content.attrs }; - } - - if (content.marks) { - processed.marks = content.marks.map((mark) => ({ - type: mark.type, - ...(mark.attrs && { attrs: { ...mark.attrs } }), - })); - } - - if (content.content) { - processed.content = processContent(content.content) as ContentNode[]; - } - - return processed; + if (!content) return content; + + // Handle arrays + if (Array.isArray(content)) { + return content.map((node) => processContent(node) as ContentNode); + } + + // Create a new plain object with only the necessary properties + const processed: ContentNode = { + type: content.type, + }; + + if (content.text !== undefined) { + processed.text = content.text; + } + + if (content.attrs) { + processed.attrs = { ...content.attrs }; + } + + if (content.marks) { + processed.marks = content.marks.map((mark) => ({ + type: mark.type, + ...(mark.attrs && { attrs: { ...mark.attrs } }), + })); + } + + if (content.content) { + processed.content = processContent(content.content) as ContentNode[]; + } + + return processed; } export const updatePolicyAction = authActionClient - .schema(updatePolicySchema) - .metadata({ - name: "update-policy", - track: { - event: "update-policy", - description: "Update Policy", - channel: "server", - }, - }) - .action(async ({ parsedInput, ctx }) => { - const { id, content } = parsedInput; - const { activeOrganizationId } = ctx.session; - const { user } = ctx; - - if (!activeOrganizationId) { - return { - success: false, - error: "Not authorized", - }; - } - - if (!user) { - return { - success: false, - error: "Not authorized", - }; - } - - try { - const policy = await db.policy.findUnique({ - where: { id, organizationId: activeOrganizationId }, - }); - - if (!policy) { - return { - success: false, - error: "Policy not found", - }; - } - - // Create a new plain object from the content - const processedContent = JSON.parse( - JSON.stringify(processContent(content as ContentNode)), - ); - - await db.policy.update({ - where: { id }, - data: { content: processedContent.content }, - }); - - revalidatePath(`/${activeOrganizationId}/policies/${id}`); - revalidatePath(`/${activeOrganizationId}/policies`); - revalidateTag(`user_${user.id}`); - - return { - success: true, - }; - } catch (error) { - logger.error("Error updating policy:", { - error, - errorMessage: - error instanceof Error ? error.message : "Unknown error", - errorStack: error instanceof Error ? error.stack : undefined, - }); - return { - success: false, - error: - error instanceof Error - ? error.message - : "Failed to update policy", - }; - } - }); + .schema(updatePolicySchema) + .metadata({ + name: "update-policy", + track: { + event: "update-policy", + description: "Update Policy", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { id, content } = parsedInput; + const { activeOrganizationId } = ctx.session; + const { user } = ctx; + + if (!activeOrganizationId) { + return { + success: false, + error: "Not authorized", + }; + } + + if (!user) { + return { + success: false, + error: "Not authorized", + }; + } + + try { + const policy = await db.policy.findUnique({ + where: { id, organizationId: activeOrganizationId }, + }); + + if (!policy) { + return { + success: false, + error: "Policy not found", + }; + } + + // Create a new plain object from the content + const processedContent = JSON.parse( + JSON.stringify(processContent(content as ContentNode)), + ); + + await db.policy.update({ + where: { id }, + data: { content: processedContent.content }, + }); + + revalidatePath(`/${activeOrganizationId}/policies/${id}`); + revalidatePath(`/${activeOrganizationId}/policies`); + revalidateTag(`user_${user.id}`); + + return { + success: true, + }; + } catch (error) { + logger.error("Error updating policy:", { + error, + errorMessage: error instanceof Error ? error.message : "Unknown error", + errorStack: error instanceof Error ? error.stack : undefined, + }); + return { + success: false, + error: + error instanceof Error ? error.message : "Failed to update policy", + }; + } + }); diff --git a/apps/app/src/actions/policies/update-policy-form-action.ts b/apps/app/src/actions/policies/update-policy-form-action.ts index 26b2892c69..f8c9e850d1 100644 --- a/apps/app/src/actions/policies/update-policy-form-action.ts +++ b/apps/app/src/actions/policies/update-policy-form-action.ts @@ -10,108 +10,108 @@ import { updatePolicyFormSchema } from "../schema"; // Helper function to calculate next review date based on frequency function calculateNextReviewDate( - frequency: string, - baseDate: Date = new Date(), + frequency: string, + baseDate: Date = new Date(), ): Date { - const nextDate = new Date(baseDate); + const nextDate = new Date(baseDate); - switch (frequency) { - case "monthly": - nextDate.setMonth(nextDate.getMonth() + 1); - break; - case "quarterly": - nextDate.setMonth(nextDate.getMonth() + 3); - break; - case "yearly": - nextDate.setFullYear(nextDate.getFullYear() + 1); - break; - default: - // If frequency is not recognized, default to yearly - nextDate.setFullYear(nextDate.getFullYear() + 1); - } + switch (frequency) { + case "monthly": + nextDate.setMonth(nextDate.getMonth() + 1); + break; + case "quarterly": + nextDate.setMonth(nextDate.getMonth() + 3); + break; + case "yearly": + nextDate.setFullYear(nextDate.getFullYear() + 1); + break; + default: + // If frequency is not recognized, default to yearly + nextDate.setFullYear(nextDate.getFullYear() + 1); + } - return nextDate; + return nextDate; } export const updatePolicyFormAction = authActionClient - .schema(updatePolicyFormSchema) - .metadata({ - name: "update-policy-form", - track: { - event: "update-policy-form", - description: "Update Policy", - channel: "server", - }, - }) - .action(async ({ parsedInput, ctx }) => { - const { - id, - status, - assigneeId, - department, - review_frequency, - review_date, - isRequiredToSign, - } = parsedInput; - const { user, session } = ctx; + .schema(updatePolicyFormSchema) + .metadata({ + name: "update-policy-form", + track: { + event: "update-policy-form", + description: "Update Policy", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { + id, + status, + assigneeId, + department, + review_frequency, + review_date, + isRequiredToSign, + } = parsedInput; + const { user, session } = ctx; - if (!user.id || !session.activeOrganizationId) { - throw new Error("Unauthorized"); - } + if (!user.id || !session.activeOrganizationId) { + throw new Error("Unauthorized"); + } - try { - // Get the current policy to check if status is changing to published - const currentPolicy = await db.policy.findUnique({ - where: { - id, - organizationId: session.activeOrganizationId, - }, - select: { - status: true, - }, - }); + try { + // Get the current policy to check if status is changing to published + const currentPolicy = await db.policy.findUnique({ + where: { + id, + organizationId: session.activeOrganizationId, + }, + select: { + status: true, + }, + }); - // Determine if we need to update the review date - let reviewDate = review_date; - let lastPublishedAt = undefined; + // Determine if we need to update the review date + let reviewDate = review_date; + let lastPublishedAt = undefined; - // If status is changing to 'published', calculate next review date based on frequency - if ( - status === PolicyStatus.published && - currentPolicy?.status !== PolicyStatus.published - ) { - reviewDate = calculateNextReviewDate(review_frequency); - lastPublishedAt = new Date(); // Set lastPublishedAt to now when publishing - } + // If status is changing to 'published', calculate next review date based on frequency + if ( + status === PolicyStatus.published && + currentPolicy?.status !== PolicyStatus.published + ) { + reviewDate = calculateNextReviewDate(review_frequency); + lastPublishedAt = new Date(); // Set lastPublishedAt to now when publishing + } - await db.policy.update({ - where: { - id, - organizationId: session.activeOrganizationId, - }, - data: { - status, - assigneeId, - department, - frequency: review_frequency, - reviewDate, - isRequiredToSign: isRequiredToSign === "required", - ...(lastPublishedAt && { lastPublishedAt }), - }, - }); + await db.policy.update({ + where: { + id, + organizationId: session.activeOrganizationId, + }, + data: { + status, + assigneeId, + department, + frequency: review_frequency, + reviewDate, + isRequiredToSign: isRequiredToSign === "required", + ...(lastPublishedAt && { lastPublishedAt }), + }, + }); - revalidatePath(`/${session.activeOrganizationId}/policies`); - revalidatePath(`/${session.activeOrganizationId}/policies/${id}`); - revalidateTag("policies"); + revalidatePath(`/${session.activeOrganizationId}/policies`); + revalidatePath(`/${session.activeOrganizationId}/policies/${id}`); + revalidateTag("policies"); - return { - success: true, - }; - } catch (error) { - console.error("Error updating policy:", error); + return { + success: true, + }; + } catch (error) { + console.error("Error updating policy:", error); - return { - success: false, - }; - } - }); + return { + success: false, + }; + } + }); diff --git a/apps/app/src/actions/policies/update-policy-overview-action.ts b/apps/app/src/actions/policies/update-policy-overview-action.ts index 0a962295b7..d56d01db37 100644 --- a/apps/app/src/actions/policies/update-policy-overview-action.ts +++ b/apps/app/src/actions/policies/update-policy-overview-action.ts @@ -8,72 +8,71 @@ import { authActionClient } from "../safe-action"; import { updatePolicyOverviewSchema } from "../schema"; export const updatePolicyOverviewAction = authActionClient - .schema(updatePolicyOverviewSchema) - .metadata({ - name: "update-policy-overview", - track: { - event: "update-policy-overview", - description: "Update Policy", - channel: "server", - }, - }) - .action(async ({ parsedInput, ctx }) => { - const { id, title, description, isRequiredToSign } = parsedInput; - const { user, session } = ctx; + .schema(updatePolicyOverviewSchema) + .metadata({ + name: "update-policy-overview", + track: { + event: "update-policy-overview", + description: "Update Policy", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { id, title, description, isRequiredToSign } = parsedInput; + const { user, session } = ctx; - if (!user) { - return { - success: false, - error: "Not authorized", - }; - } + if (!user) { + return { + success: false, + error: "Not authorized", + }; + } - if (!session.activeOrganizationId) { - return { - success: false, - error: "Not authorized", - }; - } + if (!session.activeOrganizationId) { + return { + success: false, + error: "Not authorized", + }; + } - try { - const policy = await db.policy.findUnique({ - where: { id, organizationId: session.activeOrganizationId }, - }); + try { + const policy = await db.policy.findUnique({ + where: { id, organizationId: session.activeOrganizationId }, + }); - if (!policy) { - return { - success: false, - error: "Policy not found", - }; - } + if (!policy) { + return { + success: false, + error: "Policy not found", + }; + } - await db.policy.update({ - where: { id }, - data: { - name: title, - description, - // Use type assertion to handle the new field - // that might not be in the generated types yet - ...(isRequiredToSign !== undefined - ? ({ - isRequiredToSign: - isRequiredToSign === "required", - } as any) - : {}), - }, - }); + await db.policy.update({ + where: { id }, + data: { + name: title, + description, + // Use type assertion to handle the new field + // that might not be in the generated types yet + ...(isRequiredToSign !== undefined + ? ({ + isRequiredToSign: isRequiredToSign === "required", + } as any) + : {}), + }, + }); - revalidatePath(`/${session.activeOrganizationId}/policies/${id}`); - revalidatePath(`/${session.activeOrganizationId}/policies/all`); - revalidatePath(`/${session.activeOrganizationId}/policies`); + revalidatePath(`/${session.activeOrganizationId}/policies/${id}`); + revalidatePath(`/${session.activeOrganizationId}/policies/all`); + revalidatePath(`/${session.activeOrganizationId}/policies`); - return { - success: true, - }; - } catch (error) { - return { - success: false, - error: "Failed to update policy overview", - }; - } - }); + return { + success: true, + }; + } catch (error) { + return { + success: false, + error: "Failed to update policy overview", + }; + } + }); diff --git a/apps/app/src/actions/research-vendor.ts b/apps/app/src/actions/research-vendor.ts index fe2039d683..b9c87da387 100644 --- a/apps/app/src/actions/research-vendor.ts +++ b/apps/app/src/actions/research-vendor.ts @@ -6,47 +6,47 @@ import { z } from "zod"; import { authActionClient } from "./safe-action"; export const researchVendorAction = authActionClient - .schema( - z.object({ - website: z.string().url({ message: "Invalid URL format" }), - }), - ) - .metadata({ - name: "research-vendor", - }) - .action(async ({ parsedInput: { website }, ctx: { session } }) => { - try { - const { activeOrganizationId } = session; + .schema( + z.object({ + website: z.string().url({ message: "Invalid URL format" }), + }), + ) + .metadata({ + name: "research-vendor", + }) + .action(async ({ parsedInput: { website }, ctx: { session } }) => { + try { + const { activeOrganizationId } = session; - if (!activeOrganizationId) { - return { - success: false, - error: "Not authorized", - }; - } + if (!activeOrganizationId) { + return { + success: false, + error: "Not authorized", + }; + } - const handle = await tasks.trigger( - "research-vendor", - { - website, - }, - ); + const handle = await tasks.trigger( + "research-vendor", + { + website, + }, + ); - return { - success: true, - handle, - }; - } catch (error) { - console.error("Error in researchVendorAction:", error); + return { + success: true, + handle, + }; + } catch (error) { + console.error("Error in researchVendorAction:", error); - return { - success: false, - error: { - message: - error instanceof Error - ? error.message - : "An unexpected error occurred.", - }, - }; - } - }); + return { + success: false, + error: { + message: + error instanceof Error + ? error.message + : "An unexpected error occurred.", + }, + }; + } + }); diff --git a/apps/app/src/actions/risk/create-risk-action.ts b/apps/app/src/actions/risk/create-risk-action.ts index fddd6a9d3d..3b559951c5 100644 --- a/apps/app/src/actions/risk/create-risk-action.ts +++ b/apps/app/src/actions/risk/create-risk-action.ts @@ -10,47 +10,47 @@ import { authActionClient } from "../safe-action"; import { createRiskSchema } from "../schema"; export const createRiskAction = authActionClient - .schema(createRiskSchema) - .metadata({ - name: "create-risk", - track: { - event: "create-risk", - channel: "server", - }, - }) - .action(async ({ parsedInput, ctx }) => { - const { title, description, category, department, assigneeId } = - parsedInput; - const { user, session } = ctx; + .schema(createRiskSchema) + .metadata({ + name: "create-risk", + track: { + event: "create-risk", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { title, description, category, department, assigneeId } = + parsedInput; + const { user, session } = ctx; - if (!user.id || !session.activeOrganizationId) { - throw new Error("Invalid user input"); - } + if (!user.id || !session.activeOrganizationId) { + throw new Error("Invalid user input"); + } - try { - await db.risk.create({ - data: { - title, - description, - category, - department, - likelihood: Likelihood.very_unlikely, - impact: Impact.insignificant, - assigneeId: assigneeId, - organizationId: session.activeOrganizationId, - }, - }); + try { + await db.risk.create({ + data: { + title, + description, + category, + department, + likelihood: Likelihood.very_unlikely, + impact: Impact.insignificant, + assigneeId: assigneeId, + organizationId: session.activeOrganizationId, + }, + }); - revalidatePath(`/${session.activeOrganizationId}/risk`); - revalidatePath(`/${session.activeOrganizationId}/risk/register`); - revalidateTag(`risk_${session.activeOrganizationId}`); + revalidatePath(`/${session.activeOrganizationId}/risk`); + revalidatePath(`/${session.activeOrganizationId}/risk/register`); + revalidateTag(`risk_${session.activeOrganizationId}`); - return { - success: true, - }; - } catch (error) { - return { - success: false, - }; - } - }); + return { + success: true, + }; + } catch (error) { + return { + success: false, + }; + } + }); diff --git a/apps/app/src/actions/risk/task/revalidate-upload.ts b/apps/app/src/actions/risk/task/revalidate-upload.ts index 8e78c0490e..73bb2aa230 100644 --- a/apps/app/src/actions/risk/task/revalidate-upload.ts +++ b/apps/app/src/actions/risk/task/revalidate-upload.ts @@ -6,30 +6,30 @@ import { revalidatePath } from "next/cache"; import { revalidateTag } from "next/cache"; export const revalidateUpload = authActionClient - .schema(uploadTaskFileSchema) - .metadata({ - name: "upload-task-file", - track: { - event: "upload-task-file", - channel: "server", - }, - }) - .action(async ({ parsedInput, ctx }) => { - const { riskId, taskId } = parsedInput; - const { session } = ctx; + .schema(uploadTaskFileSchema) + .metadata({ + name: "upload-task-file", + track: { + event: "upload-task-file", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { riskId, taskId } = parsedInput; + const { session } = ctx; - if (!session.activeOrganizationId) { - throw new Error("Unauthorized"); - } + if (!session.activeOrganizationId) { + throw new Error("Unauthorized"); + } - revalidatePath(`/${session.activeOrganizationId}/risk/${riskId}`); - revalidatePath( - `/${session.activeOrganizationId}/risk/${riskId}/tasks/${taskId}`, - ); - revalidateTag("risk-cache"); + revalidatePath(`/${session.activeOrganizationId}/risk/${riskId}`); + revalidatePath( + `/${session.activeOrganizationId}/risk/${riskId}/tasks/${taskId}`, + ); + revalidateTag("risk-cache"); - return { - riskId, - taskId, - }; - }); + return { + riskId, + taskId, + }; + }); diff --git a/apps/app/src/actions/risk/task/update-task-action.ts b/apps/app/src/actions/risk/task/update-task-action.ts index 893c056b1e..a113033095 100644 --- a/apps/app/src/actions/risk/task/update-task-action.ts +++ b/apps/app/src/actions/risk/task/update-task-action.ts @@ -9,57 +9,55 @@ import { authActionClient } from "../../safe-action"; import { updateTaskSchema } from "../../schema"; export const updateTaskAction = authActionClient - .schema(updateTaskSchema) - .metadata({ - name: "update-task", - track: { - event: "update-task", - channel: "server", - }, - }) - .action(async ({ parsedInput, ctx }) => { - const { id, status, assigneeId, title, description } = parsedInput; - const { session } = ctx; - - if (!session.activeOrganizationId) { - throw new Error("Invalid user input"); - } - - try { - const task = await db.task.findUnique({ - where: { - id: id, - }, - }); - - if (!task) { - throw new Error("Task not found"); - } - - await db.task.update({ - where: { - id: id, - organizationId: session.activeOrganizationId, - }, - data: { - status: status as TaskStatus, - assigneeId, - title: title, - description: description, - updatedAt: new Date(), - }, - }); - - revalidatePath(`/${session.activeOrganizationId}/risk`); - revalidatePath(`/${session.activeOrganizationId}/risk/${id}`); - revalidatePath( - `/${session.activeOrganizationId}/risk/${id}/tasks/${id}`, - ); - revalidateTag("risks"); - - return { success: true }; - } catch (error) { - console.error(error); - return { success: false }; - } - }); + .schema(updateTaskSchema) + .metadata({ + name: "update-task", + track: { + event: "update-task", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { id, status, assigneeId, title, description } = parsedInput; + const { session } = ctx; + + if (!session.activeOrganizationId) { + throw new Error("Invalid user input"); + } + + try { + const task = await db.task.findUnique({ + where: { + id: id, + }, + }); + + if (!task) { + throw new Error("Task not found"); + } + + await db.task.update({ + where: { + id: id, + organizationId: session.activeOrganizationId, + }, + data: { + status: status as TaskStatus, + assigneeId, + title: title, + description: description, + updatedAt: new Date(), + }, + }); + + revalidatePath(`/${session.activeOrganizationId}/risk`); + revalidatePath(`/${session.activeOrganizationId}/risk/${id}`); + revalidatePath(`/${session.activeOrganizationId}/risk/${id}/tasks/${id}`); + revalidateTag("risks"); + + return { success: true }; + } catch (error) { + console.error(error); + return { success: false }; + } + }); diff --git a/apps/app/src/actions/risk/update-inherent-risk-action.ts b/apps/app/src/actions/risk/update-inherent-risk-action.ts index 646a14b4f3..f21f0e669d 100644 --- a/apps/app/src/actions/risk/update-inherent-risk-action.ts +++ b/apps/app/src/actions/risk/update-inherent-risk-action.ts @@ -6,46 +6,46 @@ import { authActionClient } from "../safe-action"; import { updateInherentRiskSchema } from "../schema"; export const updateInherentRiskAction = authActionClient - .schema(updateInherentRiskSchema) - .metadata({ - name: "update-inherent-risk", - track: { - event: "update-inherent-risk", - channel: "server", - }, - }) - .action(async ({ parsedInput, ctx }) => { - const { id, probability, impact } = parsedInput; - const { session } = ctx; + .schema(updateInherentRiskSchema) + .metadata({ + name: "update-inherent-risk", + track: { + event: "update-inherent-risk", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { id, probability, impact } = parsedInput; + const { session } = ctx; - if (!session.activeOrganizationId) { - throw new Error("Invalid organization"); - } + if (!session.activeOrganizationId) { + throw new Error("Invalid organization"); + } - try { - await db.risk.update({ - where: { - id, - organizationId: session.activeOrganizationId, - }, - data: { - likelihood: probability, - impact, - }, - }); + try { + await db.risk.update({ + where: { + id, + organizationId: session.activeOrganizationId, + }, + data: { + likelihood: probability, + impact, + }, + }); - revalidatePath(`/${session.activeOrganizationId}/risk`); - revalidatePath(`/${session.activeOrganizationId}/risk/register`); - revalidatePath(`/${session.activeOrganizationId}/risk/${id}`); - revalidateTag("risks"); + revalidatePath(`/${session.activeOrganizationId}/risk`); + revalidatePath(`/${session.activeOrganizationId}/risk/register`); + revalidatePath(`/${session.activeOrganizationId}/risk/${id}`); + revalidateTag("risks"); - return { - success: true, - }; - } catch (error) { - console.error("Error updating inherent risk:", error); - return { - success: false, - }; - } - }); + return { + success: true, + }; + } catch (error) { + console.error("Error updating inherent risk:", error); + return { + success: false, + }; + } + }); diff --git a/apps/app/src/actions/risk/update-residual-risk-action.ts b/apps/app/src/actions/risk/update-residual-risk-action.ts index dc576e1180..020a9b2ed6 100644 --- a/apps/app/src/actions/risk/update-residual-risk-action.ts +++ b/apps/app/src/actions/risk/update-residual-risk-action.ts @@ -7,62 +7,62 @@ import { authActionClient } from "../safe-action"; import { updateResidualRiskSchema } from "../schema"; function mapNumericToImpact(value: number): Impact { - if (value <= 2) return Impact.insignificant; - if (value <= 4) return Impact.minor; - if (value <= 6) return Impact.moderate; - if (value <= 8) return Impact.major; - return Impact.severe; + if (value <= 2) return Impact.insignificant; + if (value <= 4) return Impact.minor; + if (value <= 6) return Impact.moderate; + if (value <= 8) return Impact.major; + return Impact.severe; } function mapNumericToLikelihood(value: number): Likelihood { - if (value <= 2) return Likelihood.very_unlikely; - if (value <= 4) return Likelihood.unlikely; - if (value <= 6) return Likelihood.possible; - if (value <= 8) return Likelihood.likely; - return Likelihood.very_likely; + if (value <= 2) return Likelihood.very_unlikely; + if (value <= 4) return Likelihood.unlikely; + if (value <= 6) return Likelihood.possible; + if (value <= 8) return Likelihood.likely; + return Likelihood.very_likely; } export const updateResidualRiskAction = authActionClient - .schema(updateResidualRiskSchema) - .metadata({ - name: "update-residual-risk", - track: { - event: "update-residual-risk", - channel: "server", - }, - }) - .action(async ({ parsedInput, ctx }) => { - const { id, probability, impact } = parsedInput; - const { session } = ctx; + .schema(updateResidualRiskSchema) + .metadata({ + name: "update-residual-risk", + track: { + event: "update-residual-risk", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { id, probability, impact } = parsedInput; + const { session } = ctx; - if (!session.activeOrganizationId) { - throw new Error("Invalid organization"); - } + if (!session.activeOrganizationId) { + throw new Error("Invalid organization"); + } - try { - await db.risk.update({ - where: { - id, - organizationId: session.activeOrganizationId, - }, - data: { - residualLikelihood: mapNumericToLikelihood(probability), - residualImpact: mapNumericToImpact(impact), - }, - }); + try { + await db.risk.update({ + where: { + id, + organizationId: session.activeOrganizationId, + }, + data: { + residualLikelihood: mapNumericToLikelihood(probability), + residualImpact: mapNumericToImpact(impact), + }, + }); - revalidatePath(`/${session.activeOrganizationId}/risk`); - revalidatePath(`/${session.activeOrganizationId}/risk/register`); - revalidatePath(`/${session.activeOrganizationId}/risk/${id}`); - revalidateTag("risks"); + revalidatePath(`/${session.activeOrganizationId}/risk`); + revalidatePath(`/${session.activeOrganizationId}/risk/register`); + revalidatePath(`/${session.activeOrganizationId}/risk/${id}`); + revalidateTag("risks"); - return { - success: true, - }; - } catch (error) { - console.error("Error updating residual risk:", error); - return { - success: false, - }; - } - }); + return { + success: true, + }; + } catch (error) { + console.error("Error updating residual risk:", error); + return { + success: false, + }; + } + }); diff --git a/apps/app/src/actions/risk/update-residual-risk-enum-action.ts b/apps/app/src/actions/risk/update-residual-risk-enum-action.ts index e0d00210a9..be5c91a1f1 100644 --- a/apps/app/src/actions/risk/update-residual-risk-enum-action.ts +++ b/apps/app/src/actions/risk/update-residual-risk-enum-action.ts @@ -6,46 +6,46 @@ import { authActionClient } from "../safe-action"; import { updateResidualRiskEnumSchema } from "../schema"; // Use the new enum schema export const updateResidualRiskEnumAction = authActionClient - .schema(updateResidualRiskEnumSchema) // Use the new enum schema - .metadata({ - name: "update-residual-risk-enum", // New name - track: { - event: "update-residual-risk", // Keep original event if desired - channel: "server", - }, - }) - .action(async ({ parsedInput, ctx }) => { - const { id, probability, impact } = parsedInput; // These are now enums - const { session } = ctx; + .schema(updateResidualRiskEnumSchema) // Use the new enum schema + .metadata({ + name: "update-residual-risk-enum", // New name + track: { + event: "update-residual-risk", // Keep original event if desired + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { id, probability, impact } = parsedInput; // These are now enums + const { session } = ctx; - if (!session.activeOrganizationId) { - throw new Error("Invalid organization"); - } + if (!session.activeOrganizationId) { + throw new Error("Invalid organization"); + } - try { - await db.risk.update({ - where: { - id, - organizationId: session.activeOrganizationId, - }, - data: { - residualLikelihood: probability, // Use enum directly - residualImpact: impact, // Use enum directly - }, - }); + try { + await db.risk.update({ + where: { + id, + organizationId: session.activeOrganizationId, + }, + data: { + residualLikelihood: probability, // Use enum directly + residualImpact: impact, // Use enum directly + }, + }); - revalidatePath(`/${session.activeOrganizationId}/risk`); - revalidatePath(`/${session.activeOrganizationId}/risk/register`); - revalidatePath(`/${session.activeOrganizationId}/risk/${id}`); - revalidateTag("risks"); + revalidatePath(`/${session.activeOrganizationId}/risk`); + revalidatePath(`/${session.activeOrganizationId}/risk/register`); + revalidatePath(`/${session.activeOrganizationId}/risk/${id}`); + revalidateTag("risks"); - return { - success: true, - }; - } catch (error) { - console.error("Error updating residual risk (enum):", error); - return { - success: false, - }; - } - }); + return { + success: true, + }; + } catch (error) { + console.error("Error updating residual risk (enum):", error); + return { + success: false, + }; + } + }); diff --git a/apps/app/src/actions/risk/update-risk-action.ts b/apps/app/src/actions/risk/update-risk-action.ts index e822a8f29a..fb1420b816 100644 --- a/apps/app/src/actions/risk/update-risk-action.ts +++ b/apps/app/src/actions/risk/update-risk-action.ts @@ -8,59 +8,52 @@ import { authActionClient } from "../safe-action"; import { updateRiskSchema } from "../schema"; export const updateRiskAction = authActionClient - .schema(updateRiskSchema) - .metadata({ - name: "update-risk", - track: { - event: "update-risk", - channel: "server", - }, - }) - .action(async ({ parsedInput, ctx }) => { - const { - id, - title, - description, - category, - department, - assigneeId, - status, - } = parsedInput; - const { session } = ctx; - - if (!session.activeOrganizationId) { - throw new Error("Invalid user input"); - } - - try { - await db.risk.update({ - where: { - id, - organizationId: session.activeOrganizationId, - }, - data: { - title: title, - description: description, - assigneeId: assigneeId, - category: category, - department: department, - status: status, - }, - }); - - revalidatePath(`/${session.activeOrganizationId}/risk`); - revalidatePath(`/${session.activeOrganizationId}/risk/register`); - revalidatePath(`/${session.activeOrganizationId}/risk/${id}`); - revalidateTag("risks"); - - return { - success: true, - }; - } catch (error) { - console.error("Error updating risk:", error); - - return { - success: false, - }; - } - }); + .schema(updateRiskSchema) + .metadata({ + name: "update-risk", + track: { + event: "update-risk", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { id, title, description, category, department, assigneeId, status } = + parsedInput; + const { session } = ctx; + + if (!session.activeOrganizationId) { + throw new Error("Invalid user input"); + } + + try { + await db.risk.update({ + where: { + id, + organizationId: session.activeOrganizationId, + }, + data: { + title: title, + description: description, + assigneeId: assigneeId, + category: category, + department: department, + status: status, + }, + }); + + revalidatePath(`/${session.activeOrganizationId}/risk`); + revalidatePath(`/${session.activeOrganizationId}/risk/register`); + revalidatePath(`/${session.activeOrganizationId}/risk/${id}`); + revalidateTag("risks"); + + return { + success: true, + }; + } catch (error) { + console.error("Error updating risk:", error); + + return { + success: false, + }; + } + }); diff --git a/apps/app/src/actions/safe-action.ts b/apps/app/src/actions/safe-action.ts index 71c15d89a6..8a67774e64 100644 --- a/apps/app/src/actions/safe-action.ts +++ b/apps/app/src/actions/safe-action.ts @@ -7,8 +7,8 @@ import { AuditLogEntityType } from "@comp/db/types"; import { client } from "@comp/kv"; import { Ratelimit } from "@upstash/ratelimit"; import { - DEFAULT_SERVER_ERROR_MESSAGE, - createSafeActionClient, + DEFAULT_SERVER_ERROR_MESSAGE, + createSafeActionClient, } from "next-safe-action"; import { headers } from "next/headers"; import { z } from "zod"; @@ -16,198 +16,195 @@ import { z } from "zod"; let ratelimit: Ratelimit | undefined; if (env.UPSTASH_REDIS_REST_URL && env.UPSTASH_REDIS_REST_TOKEN) { - ratelimit = new Ratelimit({ - limiter: Ratelimit.fixedWindow(10, "10s"), - redis: client, - }); + ratelimit = new Ratelimit({ + limiter: Ratelimit.fixedWindow(10, "10s"), + redis: client, + }); } export const actionClientWithMeta = createSafeActionClient({ - handleServerError(e) { - if (e instanceof Error) { - return e.message; - } - - return DEFAULT_SERVER_ERROR_MESSAGE; - }, - defineMetadataSchema() { - return z.object({ - name: z.string(), - ip: z.string().optional(), - userAgent: z.string().optional(), - track: z - .object({ - description: z.string().optional(), - event: z.string(), - channel: z.string(), - }) - .optional(), - }); - }, + handleServerError(e) { + if (e instanceof Error) { + return e.message; + } + + return DEFAULT_SERVER_ERROR_MESSAGE; + }, + defineMetadataSchema() { + return z.object({ + name: z.string(), + ip: z.string().optional(), + userAgent: z.string().optional(), + track: z + .object({ + description: z.string().optional(), + event: z.string(), + channel: z.string(), + }) + .optional(), + }); + }, }); export const authActionClient = actionClientWithMeta - .use(async ({ next, clientInput }) => { - const response = await auth.api.getSession({ - headers: await headers(), - }); - - const { session, user } = response ?? {}; - - if (!session) { - throw new Error("Unauthorized"); - } - - const result = await next({ - ctx: { - user: user, - session: session, - }, - }); - - if (process.env.NODE_ENV === "development") { - logger("Input ->", clientInput as string); - logger("Result ->", result.data as string); - - return result; - } - - return result; - }) - .use(async ({ next, metadata }) => { - const headersList = await headers(); - let remaining: number | undefined; - - if (ratelimit) { - const { success, remaining } = await ratelimit.limit( - `${headersList.get("x-forwarded-for")}-${metadata.name}`, - ); - - if (!success) { - throw new Error("Too many requests"); - } - } - - return next({ - ctx: { - ip: headersList.get("x-forwarded-for"), - userAgent: headersList.get("user-agent"), - ratelimit: { - remaining: remaining ?? 0, - }, - }, - }); - }) - .use(async ({ next, metadata, ctx }) => { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - if (!session) { - throw new Error("Unauthorized"); - } - - if (metadata.track) { - track(session.user.id, metadata.track.event, { - channel: metadata.track.channel, - email: session.user.email, - name: session.user.name, - organizationId: session.session.activeOrganizationId, - }); - } - - return next({ - ctx: { - user: session.user, - }, - }); - }) - .use(async ({ next, metadata, clientInput }) => { - const headersList = await headers(); - const session = await auth.api.getSession({ - headers: headersList, - }); - - const member = await auth.api.getActiveMember({ - headers: headersList, - }); - - if (!session) { - throw new Error("Unauthorized"); - } - - if (!session.session.activeOrganizationId) { - throw new Error("Organization not found"); - } - - if (!member) { - throw new Error("Member not found"); - } - - const data = { - userId: session.user.id, - email: session.user.email, - name: session.user.name, - organizationId: session.session.activeOrganizationId, - action: metadata.name, - input: clientInput, - ipAddress: headersList.get("x-forwarded-for") || null, - userAgent: headersList.get("user-agent") || null, - }; - - const entityId = - (clientInput as { entityId: string })?.entityId || null; - - let entityType = null; - - const mapEntityType: Record = { - pol_: AuditLogEntityType.policy, - ctl_: AuditLogEntityType.control, - tsk_: AuditLogEntityType.task, - vnd_: AuditLogEntityType.vendor, - rsk_: AuditLogEntityType.risk, - org_: AuditLogEntityType.organization, - frm_: AuditLogEntityType.framework, - req_: AuditLogEntityType.requirement, - mem_: AuditLogEntityType.people, - itr_: AuditLogEntityType.tests, - int_: AuditLogEntityType.integration, - frk_rq_: AuditLogEntityType.framework, - frk_ctrl_: AuditLogEntityType.framework, - frk_req_: AuditLogEntityType.framework, - }; - - if (entityId) { - const parts = entityId.split("_"); - const prefix = `${parts[0]}_`; - - // Handle special case prefixes with multiple parts - if (parts.length > 2) { - const complexPrefix = `${prefix}${parts[1]}_`; - entityType = - mapEntityType[complexPrefix] || - mapEntityType[prefix] || - null; - } else { - entityType = mapEntityType[prefix] || null; - } - } - - try { - await db.auditLog.create({ - data: { - data: JSON.stringify(data), - memberId: member.id, - userId: session.user.id, - description: metadata.track?.description || null, - organizationId: session.session.activeOrganizationId, - entityId, - entityType, - }, - }); - } catch (error) { - logger("Audit log error:", error); - } - - return next(); - }); + .use(async ({ next, clientInput }) => { + const response = await auth.api.getSession({ + headers: await headers(), + }); + + const { session, user } = response ?? {}; + + if (!session) { + throw new Error("Unauthorized"); + } + + const result = await next({ + ctx: { + user: user, + session: session, + }, + }); + + if (process.env.NODE_ENV === "development") { + logger("Input ->", clientInput as string); + logger("Result ->", result.data as string); + + return result; + } + + return result; + }) + .use(async ({ next, metadata }) => { + const headersList = await headers(); + let remaining: number | undefined; + + if (ratelimit) { + const { success, remaining } = await ratelimit.limit( + `${headersList.get("x-forwarded-for")}-${metadata.name}`, + ); + + if (!success) { + throw new Error("Too many requests"); + } + } + + return next({ + ctx: { + ip: headersList.get("x-forwarded-for"), + userAgent: headersList.get("user-agent"), + ratelimit: { + remaining: remaining ?? 0, + }, + }, + }); + }) + .use(async ({ next, metadata, ctx }) => { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session) { + throw new Error("Unauthorized"); + } + + if (metadata.track) { + track(session.user.id, metadata.track.event, { + channel: metadata.track.channel, + email: session.user.email, + name: session.user.name, + organizationId: session.session.activeOrganizationId, + }); + } + + return next({ + ctx: { + user: session.user, + }, + }); + }) + .use(async ({ next, metadata, clientInput }) => { + const headersList = await headers(); + const session = await auth.api.getSession({ + headers: headersList, + }); + + const member = await auth.api.getActiveMember({ + headers: headersList, + }); + + if (!session) { + throw new Error("Unauthorized"); + } + + if (!session.session.activeOrganizationId) { + throw new Error("Organization not found"); + } + + if (!member) { + throw new Error("Member not found"); + } + + const data = { + userId: session.user.id, + email: session.user.email, + name: session.user.name, + organizationId: session.session.activeOrganizationId, + action: metadata.name, + input: clientInput, + ipAddress: headersList.get("x-forwarded-for") || null, + userAgent: headersList.get("user-agent") || null, + }; + + const entityId = (clientInput as { entityId: string })?.entityId || null; + + let entityType = null; + + const mapEntityType: Record = { + pol_: AuditLogEntityType.policy, + ctl_: AuditLogEntityType.control, + tsk_: AuditLogEntityType.task, + vnd_: AuditLogEntityType.vendor, + rsk_: AuditLogEntityType.risk, + org_: AuditLogEntityType.organization, + frm_: AuditLogEntityType.framework, + req_: AuditLogEntityType.requirement, + mem_: AuditLogEntityType.people, + itr_: AuditLogEntityType.tests, + int_: AuditLogEntityType.integration, + frk_rq_: AuditLogEntityType.framework, + frk_ctrl_: AuditLogEntityType.framework, + frk_req_: AuditLogEntityType.framework, + }; + + if (entityId) { + const parts = entityId.split("_"); + const prefix = `${parts[0]}_`; + + // Handle special case prefixes with multiple parts + if (parts.length > 2) { + const complexPrefix = `${prefix}${parts[1]}_`; + entityType = + mapEntityType[complexPrefix] || mapEntityType[prefix] || null; + } else { + entityType = mapEntityType[prefix] || null; + } + } + + try { + await db.auditLog.create({ + data: { + data: JSON.stringify(data), + memberId: member.id, + userId: session.user.id, + description: metadata.track?.description || null, + organizationId: session.session.activeOrganizationId, + entityId, + entityType, + }, + }); + } catch (error) { + logger("Audit log error:", error); + } + + return next(); + }); diff --git a/apps/app/src/actions/schema.ts b/apps/app/src/actions/schema.ts index befebad745..a8cfe0769f 100644 --- a/apps/app/src/actions/schema.ts +++ b/apps/app/src/actions/schema.ts @@ -1,346 +1,390 @@ import { - CommentEntityType, - Departments, - Frequency, - Impact, - Likelihood, - PolicyStatus, - RiskCategory, - RiskStatus, - TaskStatus, + CommentEntityType, + Departments, + Frequency, + Impact, + Likelihood, + PolicyStatus, + RiskCategory, + RiskStatus, + TaskStatus, } from "@comp/db/types"; import { z } from "zod"; export const organizationSchema = z.object({ - frameworkIds: z - .array(z.string()) - .min(1, "Please select at least one framework to get started with"), + frameworkIds: z + .array(z.string()) + .min(1, "Please select at least one framework to get started with"), }); export type OrganizationSchema = z.infer; export const organizationNameSchema = z.object({ - name: z - .string() - .min(1, "Organization name is required") - .max(255, "Organization name cannot exceed 255 characters"), + name: z + .string() + .min(1, "Organization name is required") + .max(255, "Organization name cannot exceed 255 characters"), }); export const subdomainAvailabilitySchema = z.object({ - subdomain: z - .string() - .min(1, "Subdomain is required") - .max(255, "Subdomain cannot exceed 255 characters") - .regex(/^[a-z0-9-]+$/, { - message: - "Subdomain can only contain lowercase letters, numbers, and hyphens", - }), + subdomain: z + .string() + .min(1, "Subdomain is required") + .max(255, "Subdomain cannot exceed 255 characters") + .regex(/^[a-z0-9-]+$/, { + message: + "Subdomain can only contain lowercase letters, numbers, and hyphens", + }), }); export const uploadSchema = z.object({ - file: z.instanceof(File), - organizationId: z.string(), + file: z.instanceof(File), + organizationId: z.string(), }); export const deleteOrganizationSchema = z.object({ - id: z.string(), - organizationId: z.string(), + id: z.string(), + organizationId: z.string(), }); export const sendFeedbackSchema = z.object({ - feedback: z.string(), + feedback: z.string(), }); export const updaterMenuSchema = z.array( - z.object({ - path: z.string(), - name: z.string(), - }), + z.object({ + path: z.string(), + name: z.string(), + }), ); export const organizationWebsiteSchema = z.object({ - website: z - .string() - .url({ - message: "Please enter a valid website that starts with https://", - }) - .max(255, "Website cannot exceed 255 characters"), + website: z + .string() + .url({ + message: "Please enter a valid website that starts with https://", + }) + .max(255, "Website cannot exceed 255 characters"), }); // Risks export const createRiskSchema = z.object({ - title: z - .string({ - required_error: "Risk name is required", - }) - .min(1, { - message: "Risk name should be at least 1 character", - }) - .max(100, { - message: "Risk name should be at most 100 characters", - }), - description: z - .string({ - required_error: "Risk description is required", - }) - .min(1, { - message: "Risk description should be at least 1 character", - }) - .max(255, { - message: "Risk description should be at most 255 characters", - }), - category: z.nativeEnum(RiskCategory, { - required_error: "Risk category is required", - }), - department: z.nativeEnum(Departments, { - required_error: "Risk department is required", - }), - assigneeId: z.string().optional().nullable(), + title: z + .string({ + required_error: "Risk name is required", + }) + .min(1, { + message: "Risk name should be at least 1 character", + }) + .max(100, { + message: "Risk name should be at most 100 characters", + }), + description: z + .string({ + required_error: "Risk description is required", + }) + .min(1, { + message: "Risk description should be at least 1 character", + }) + .max(255, { + message: "Risk description should be at most 255 characters", + }), + category: z.nativeEnum(RiskCategory, { + required_error: "Risk category is required", + }), + department: z.nativeEnum(Departments, { + required_error: "Risk department is required", + }), + assigneeId: z.string().optional().nullable(), }); export const updateRiskSchema = z.object({ - id: z.string().min(1, { - message: "Risk ID is required", - }), - title: z.string().min(1, { - message: "Risk title is required", - }), - description: z.string().min(1, { - message: "Risk description is required", - }), - category: z.nativeEnum(RiskCategory, { - required_error: "Risk category is required", - }), - department: z.nativeEnum(Departments, { - required_error: "Risk department is required", - }), - assigneeId: z.string().optional().nullable(), - status: z.nativeEnum(RiskStatus, { - required_error: "Risk status is required", - }), + id: z.string().min(1, { + message: "Risk ID is required", + }), + title: z.string().min(1, { + message: "Risk title is required", + }), + description: z.string().min(1, { + message: "Risk description is required", + }), + category: z.nativeEnum(RiskCategory, { + required_error: "Risk category is required", + }), + department: z.nativeEnum(Departments, { + required_error: "Risk department is required", + }), + assigneeId: z.string().optional().nullable(), + status: z.nativeEnum(RiskStatus, { + required_error: "Risk status is required", + }), }); export const createRiskCommentSchema = z.object({ - riskId: z.string().min(1, { - message: "Risk ID is required", - }), - content: z - .string() - .min(1, { - message: "Comment content is required", - }) - .max(1000, { - message: "Comment content should be at most 1000 characters", - }), + riskId: z.string().min(1, { + message: "Risk ID is required", + }), + content: z + .string() + .min(1, { + message: "Comment content is required", + }) + .max(1000, { + message: "Comment content should be at most 1000 characters", + }) + .transform((val) => { + // Remove any HTML tags by applying the replacement repeatedly until no changes occur + let sanitized = val; + let previousValue; + + do { + previousValue = sanitized; + sanitized = sanitized.replace(/<[^>]*>/g, ""); + } while (sanitized !== previousValue); + + return sanitized; + }), }); export const createTaskSchema = z.object({ - riskId: z.string().min(1, { - message: "Risk ID is required", - }), - title: z.string().min(1, { - message: "Task title is required", - }), - description: z.string().min(1, { - message: "Task description is required", - }), - dueDate: z.date().optional(), - assigneeId: z.string().optional().nullable(), + riskId: z.string().min(1, { + message: "Risk ID is required", + }), + title: z.string().min(1, { + message: "Task title is required", + }), + description: z.string().min(1, { + message: "Task description is required", + }), + dueDate: z.date().optional(), + assigneeId: z.string().optional().nullable(), }); export const updateTaskSchema = z.object({ - id: z.string().min(1, { - message: "Task ID is required", - }), - title: z.string().optional(), - description: z.string().optional(), - dueDate: z.date().optional(), - status: z.nativeEnum(TaskStatus, { - required_error: "Task status is required", - }), - assigneeId: z.string().optional().nullable(), + id: z.string().min(1, { + message: "Task ID is required", + }), + title: z.string().optional(), + description: z.string().optional(), + dueDate: z.date().optional(), + status: z.nativeEnum(TaskStatus, { + required_error: "Task status is required", + }), + assigneeId: z.string().optional().nullable(), }); export const createTaskCommentSchema = z.object({ - riskId: z.string().min(1, { - message: "Risk ID is required", - }), - taskId: z.string().min(1, { - message: "Task ID is required", - }), - content: z - .string() - .min(1, { - message: "Comment content is required", - }) - .max(1000, { - message: "Comment content should be at most 1000 characters", - }), + riskId: z.string().min(1, { + message: "Risk ID is required", + }), + taskId: z.string().min(1, { + message: "Task ID is required", + }), + content: z + .string() + .min(1, { + message: "Comment content is required", + }) + .max(1000, { + message: "Comment content should be at most 1000 characters", + }) + .transform((val) => { + // Remove any HTML tags by applying the replacement repeatedly until no changes occur + let sanitized = val; + let previousValue; + + do { + previousValue = sanitized; + sanitized = sanitized.replace(/<[^>]*>/g, ""); + } while (sanitized !== previousValue); + + return sanitized; + }), }); export const uploadTaskFileSchema = z.object({ - riskId: z.string().min(1, { - message: "Risk ID is required", - }), - taskId: z.string().min(1, { - message: "Task ID is required", - }), + riskId: z.string().min(1, { + message: "Risk ID is required", + }), + taskId: z.string().min(1, { + message: "Task ID is required", + }), }); // Integrations export const deleteIntegrationConnectionSchema = z.object({ - integrationName: z.string().min(1, { - message: "Integration name is required", - }), + integrationName: z.string().min(1, { + message: "Integration name is required", + }), }); export const createIntegrationSchema = z.object({ - integrationId: z.string().min(1, { - message: "Integration ID is required", - }), + integrationId: z.string().min(1, { + message: "Integration ID is required", + }), }); // Seed Data export const seedDataSchema = z.object({ - organizationId: z.string(), + organizationId: z.string(), }); export const updateInherentRiskSchema = z.object({ - id: z.string().min(1, { - message: "Risk ID is required", - }), - probability: z.nativeEnum(Likelihood), - impact: z.nativeEnum(Impact), + id: z.string().min(1, { + message: "Risk ID is required", + }), + probability: z.nativeEnum(Likelihood), + impact: z.nativeEnum(Impact), }); export const updateResidualRiskSchema = z.object({ - id: z.string().min(1, { - message: "Risk ID is required", - }), - probability: z.number().min(1).max(10), - impact: z.number().min(1).max(10), + id: z.string().min(1, { + message: "Risk ID is required", + }), + probability: z.number().min(1).max(10), + impact: z.number().min(1).max(10), }); // ADD START: Schema for enum-based residual risk update export const updateResidualRiskEnumSchema = z.object({ - id: z.string().min(1, { - message: "Risk ID is required", - }), - probability: z.nativeEnum(Likelihood), - impact: z.nativeEnum(Impact), + id: z.string().min(1, { + message: "Risk ID is required", + }), + probability: z.nativeEnum(Likelihood), + impact: z.nativeEnum(Impact), }); // ADD END // Policies export const createPolicySchema = z.object({ - title: z - .string({ required_error: "Title is required" }) - .min(1, "Title is required"), - description: z - .string({ required_error: "Description is required" }) - .min(1, "Description is required"), - frameworkIds: z.array(z.string()).optional(), - controlIds: z.array(z.string()).optional(), - entityId: z.string().optional(), + title: z + .string({ required_error: "Title is required" }) + .min(1, "Title is required"), + description: z + .string({ required_error: "Description is required" }) + .min(1, "Description is required"), + frameworkIds: z.array(z.string()).optional(), + controlIds: z.array(z.string()).optional(), + entityId: z.string().optional(), }); export type CreatePolicySchema = z.infer; export const updatePolicySchema = z.object({ - id: z.string(), - content: z.any(), - entityId: z.string(), + id: z.string(), + content: z.any(), + entityId: z.string(), }); export const addFrameworksSchema = z.object({ - organizationId: z.string().min(1, "Organization ID is required"), - frameworkIds: z - .array(z.string()) - .min(1, "Please select at least one framework to add"), + organizationId: z.string().min(1, "Organization ID is required"), + frameworkIds: z + .array(z.string()) + .min(1, "Please select at least one framework to add"), }); export const assistantSettingsSchema = z.object({ - enabled: z.boolean().optional(), + enabled: z.boolean().optional(), }); export const createEmployeeSchema = z.object({ - name: z.string().min(1, "Name is required"), - email: z.string().email("Invalid email address"), - department: z.nativeEnum(Departments, { - required_error: "Department is required", - }), - externalEmployeeId: z.string().optional(), - isActive: z.boolean().default(true), + name: z.string().min(1, "Name is required"), + email: z.string().email("Invalid email address"), + department: z.nativeEnum(Departments, { + required_error: "Department is required", + }), + externalEmployeeId: z.string().optional(), + isActive: z.boolean().default(true), }); export const updatePolicyOverviewSchema = z.object({ - id: z.string(), - title: z.string(), - description: z.string(), - isRequiredToSign: z.enum(["required", "not_required"]).optional(), - entityId: z.string(), + id: z.string(), + title: z.string(), + description: z.string(), + isRequiredToSign: z.enum(["required", "not_required"]).optional(), + entityId: z.string(), }); export const updatePolicyFormSchema = z.object({ - id: z.string(), - status: z.nativeEnum(PolicyStatus), - assigneeId: z.string().optional().nullable(), - department: z.nativeEnum(Departments), - review_frequency: z.nativeEnum(Frequency), - review_date: z.date(), - isRequiredToSign: z.enum(["required", "not_required"]), - approverId: z.string().optional().nullable(), // Added for selecting an approver - entityId: z.string(), + id: z.string(), + status: z.nativeEnum(PolicyStatus), + assigneeId: z.string().optional().nullable(), + department: z.nativeEnum(Departments), + review_frequency: z.nativeEnum(Frequency), + review_date: z.date(), + isRequiredToSign: z.enum(["required", "not_required"]), + approverId: z.string().optional().nullable(), // Added for selecting an approver + entityId: z.string(), }); export const apiKeySchema = z.object({ - name: z - .string() - .min(1, { message: "Name is required" }) - .max(64, { message: "Name must be less than 64 characters" }), - expiresAt: z.enum(["30days", "90days", "1year", "never"]), + name: z + .string() + .min(1, { message: "Name is required" }) + .max(64, { message: "Name must be less than 64 characters" }), + expiresAt: z.enum(["30days", "90days", "1year", "never"]), }); export const createPolicyCommentSchema = z.object({ - policyId: z.string().min(1, { - message: "Policy ID is required", - }), - content: z - .string() - .min(1, { - message: "Comment content is required", - }) - .max(1000, { - message: "Comment content should be at most 1000 characters", - }), + policyId: z.string().min(1, { + message: "Policy ID is required", + }), + content: z + .string() + .min(1, { + message: "Comment content is required", + }) + .max(1000, { + message: "Comment content should be at most 1000 characters", + }) + .transform((val) => { + // Remove any HTML tags by applying the replacement repeatedly until no changes occur + let sanitized = val; + let previousValue; + + do { + previousValue = sanitized; + sanitized = sanitized.replace(/<[^>]*>/g, ""); + } while (sanitized !== previousValue); + + return sanitized; + }), }); export const addCommentSchema = z.object({ - content: z - .string() - .min(1, "Comment content is required") - .max(1000, "Comment content should be at most 1000 characters") - .transform((val) => { - // Remove any HTML tags - return val.replace(/<[^>]*>/g, ""); - }), - entityId: z.string().min(1, "Entity ID is required"), - entityType: z.nativeEnum(CommentEntityType), + content: z + .string() + .min(1, "Comment content is required") + .max(1000, "Comment content should be at most 1000 characters") + .transform((val) => { + // Remove any HTML tags by applying the replacement repeatedly until no changes occur + let sanitized = val; + let previousValue; + + do { + previousValue = sanitized; + sanitized = sanitized.replace(/<[^>]*>/g, ""); + } while (sanitized !== previousValue); + + return sanitized; + }), + entityId: z.string().min(1, "Entity ID is required"), + entityType: z.nativeEnum(CommentEntityType), }); export const createContextEntrySchema = z.object({ - question: z.string().min(1, "Question is required"), - answer: z.string().min(1, "Answer is required"), - tags: z.string().optional(), // comma separated + question: z.string().min(1, "Question is required"), + answer: z.string().min(1, "Answer is required"), + tags: z.string().optional(), // comma separated }); export const updateContextEntrySchema = z.object({ - id: z.string().min(1, "ID is required"), - question: z.string().min(1, "Question is required"), - answer: z.string().min(1, "Answer is required"), - tags: z.string().optional(), + id: z.string().min(1, "ID is required"), + question: z.string().min(1, "Question is required"), + answer: z.string().min(1, "Answer is required"), + tags: z.string().optional(), }); export const deleteContextEntrySchema = z.object({ - id: z.string().min(1, "ID is required"), + id: z.string().min(1, "ID is required"), }); diff --git a/apps/app/src/actions/send-feedback-action.ts b/apps/app/src/actions/send-feedback-action.ts index 78b5f6a154..feb030500c 100644 --- a/apps/app/src/actions/send-feedback-action.ts +++ b/apps/app/src/actions/send-feedback-action.ts @@ -6,18 +6,18 @@ import { authActionClient } from "./safe-action"; import { sendFeedbackSchema } from "./schema"; export const sendFeebackAction = authActionClient - .schema(sendFeedbackSchema) - .metadata({ - name: "send-feedback", - }) - .action(async ({ parsedInput: { feedback }, ctx: { user } }) => { - if (env.DISCORD_WEBHOOK_URL) { - await axios.post(process.env.DISCORD_WEBHOOK_URL as string, { - content: `New feedback from ${user?.email}: \n\n ${feedback}`, - }); - } + .schema(sendFeedbackSchema) + .metadata({ + name: "send-feedback", + }) + .action(async ({ parsedInput: { feedback }, ctx: { user } }) => { + if (env.DISCORD_WEBHOOK_URL) { + await axios.post(process.env.DISCORD_WEBHOOK_URL as string, { + content: `New feedback from ${user?.email}: \n\n ${feedback}`, + }); + } - return { - success: true, - }; - }); + return { + success: true, + }; + }); diff --git a/apps/app/src/actions/sidebar.ts b/apps/app/src/actions/sidebar.ts index 65e6b20292..07f08912b6 100644 --- a/apps/app/src/actions/sidebar.ts +++ b/apps/app/src/actions/sidebar.ts @@ -6,19 +6,19 @@ import { cookies } from "next/headers"; import { z } from "zod"; const schema = z.object({ - isCollapsed: z.boolean(), + isCollapsed: z.boolean(), }); export const updateSidebarState = createSafeActionClient() - .schema(schema) - .action(async ({ parsedInput }) => { - const cookieStore = await cookies(); + .schema(schema) + .action(async ({ parsedInput }) => { + const cookieStore = await cookies(); - cookieStore.set({ - name: "sidebar-collapsed", - value: JSON.stringify(parsedInput.isCollapsed), - expires: addYears(new Date(), 1), - }); + cookieStore.set({ + name: "sidebar-collapsed", + value: JSON.stringify(parsedInput.isCollapsed), + expires: addYears(new Date(), 1), + }); - return { success: true }; - }); + return { success: true }; + }); diff --git a/apps/app/src/actions/types.ts b/apps/app/src/actions/types.ts index ff81dd4b5e..43114aa144 100644 --- a/apps/app/src/actions/types.ts +++ b/apps/app/src/actions/types.ts @@ -1,80 +1,80 @@ export interface ActionResponse { - success: boolean; - data?: T | null; - error?: string | { code: string; message: string }; + success: boolean; + data?: T | null; + error?: string | { code: string; message: string }; } export type ActionData = - | { - data: T; - error?: never; - } - | { - error: string; - data?: never; - }; + | { + data: T; + error?: never; + } + | { + error: string; + data?: never; + }; export type DomainVerificationStatusProps = - | "Valid Configuration" - | "Invalid Configuration" - | "Pending Verification" - | "Domain Not Found" - | "Unknown Error"; + | "Valid Configuration" + | "Invalid Configuration" + | "Pending Verification" + | "Domain Not Found" + | "Unknown Error"; // From https://vercel.com/docs/rest-api/endpoints#get-a-project-domain export interface DomainResponse { - name: string; - apexName: string; - projectId: string; - redirect?: string | null; - redirectStatusCode?: (307 | 301 | 302 | 308) | null; - gitBranch?: string | null; - updatedAt?: number; - createdAt?: number; - /** `true` if the domain is verified for use with the project. If `false` it will not be used as an alias on this project until the challenge in `verification` is completed. */ - verified: boolean; - /** A list of verification challenges, one of which must be completed to verify the domain for use on the project. After the challenge is complete `POST /projects/:idOrName/domains/:domain/verify` to verify the domain. Possible challenges: - If `verification.type = TXT` the `verification.domain` will be checked for a TXT record matching `verification.value`. */ - verification: { - type: string; - domain: string; - value: string; - reason: string; - }[]; + name: string; + apexName: string; + projectId: string; + redirect?: string | null; + redirectStatusCode?: (307 | 301 | 302 | 308) | null; + gitBranch?: string | null; + updatedAt?: number; + createdAt?: number; + /** `true` if the domain is verified for use with the project. If `false` it will not be used as an alias on this project until the challenge in `verification` is completed. */ + verified: boolean; + /** A list of verification challenges, one of which must be completed to verify the domain for use on the project. After the challenge is complete `POST /projects/:idOrName/domains/:domain/verify` to verify the domain. Possible challenges: - If `verification.type = TXT` the `verification.domain` will be checked for a TXT record matching `verification.value`. */ + verification: { + type: string; + domain: string; + value: string; + reason: string; + }[]; } // From https://vercel.com/docs/rest-api/endpoints#get-a-domain-s-configuration export interface DomainConfigResponse { - /** How we see the domain's configuration. - `CNAME`: Domain has a CNAME pointing to Vercel. - `A`: Domain's A record is resolving to Vercel. - `http`: Domain is resolving to Vercel but may be behind a Proxy. - `null`: Domain is not resolving to Vercel. */ - configuredBy?: ("CNAME" | "A" | "http") | null; - /** Which challenge types the domain can use for issuing certs. */ - acceptedChallenges?: ("dns-01" | "http-01")[]; - /** Whether or not the domain is configured AND we can automatically generate a TLS certificate. */ - misconfigured: boolean; + /** How we see the domain's configuration. - `CNAME`: Domain has a CNAME pointing to Vercel. - `A`: Domain's A record is resolving to Vercel. - `http`: Domain is resolving to Vercel but may be behind a Proxy. - `null`: Domain is not resolving to Vercel. */ + configuredBy?: ("CNAME" | "A" | "http") | null; + /** Which challenge types the domain can use for issuing certs. */ + acceptedChallenges?: ("dns-01" | "http-01")[]; + /** Whether or not the domain is configured AND we can automatically generate a TLS certificate. */ + misconfigured: boolean; } // From https://vercel.com/docs/rest-api/endpoints#verify-project-domain export interface DomainVerificationResponse { - name: string; - apexName: string; - projectId: string; - redirect?: string | null; - redirectStatusCode?: (307 | 301 | 302 | 308) | null; - gitBranch?: string | null; - updatedAt?: number; - createdAt?: number; - /** `true` if the domain is verified for use with the project. If `false` it will not be used as an alias on this project until the challenge in `verification` is completed. */ - verified: boolean; - /** A list of verification challenges, one of which must be completed to verify the domain for use on the project. After the challenge is complete `POST /projects/:idOrName/domains/:domain/verify` to verify the domain. Possible challenges: - If `verification.type = TXT` the `verification.domain` will be checked for a TXT record matching `verification.value`. */ - verification?: { - type: string; - domain: string; - value: string; - reason: string; - }[]; + name: string; + apexName: string; + projectId: string; + redirect?: string | null; + redirectStatusCode?: (307 | 301 | 302 | 308) | null; + gitBranch?: string | null; + updatedAt?: number; + createdAt?: number; + /** `true` if the domain is verified for use with the project. If `false` it will not be used as an alias on this project until the challenge in `verification` is completed. */ + verified: boolean; + /** A list of verification challenges, one of which must be completed to verify the domain for use on the project. After the challenge is complete `POST /projects/:idOrName/domains/:domain/verify` to verify the domain. Possible challenges: - If `verification.type = TXT` the `verification.domain` will be checked for a TXT record matching `verification.value`. */ + verification?: { + type: string; + domain: string; + value: string; + reason: string; + }[]; } export const UPLOAD_TYPE = { - riskTask: "risk-task", - vendorTask: "vendor-task", - policy: "policy", + riskTask: "risk-task", + vendorTask: "vendor-task", + policy: "policy", } as const; diff --git a/apps/app/src/actions/update-menu-action.ts b/apps/app/src/actions/update-menu-action.ts index fd972d8207..f1284b92d6 100644 --- a/apps/app/src/actions/update-menu-action.ts +++ b/apps/app/src/actions/update-menu-action.ts @@ -7,18 +7,18 @@ import { authActionClient } from "./safe-action"; import { updaterMenuSchema } from "./schema"; export const updateMenuAction = authActionClient - .schema(updaterMenuSchema) - .metadata({ - name: "update-menu", - }) - .action(async ({ parsedInput: value }) => { - const cookieStore = await cookies(); + .schema(updaterMenuSchema) + .metadata({ + name: "update-menu", + }) + .action(async ({ parsedInput: value }) => { + const cookieStore = await cookies(); - cookieStore.set({ - name: Cookies.MenuConfig, - value: JSON.stringify(value), - expires: addYears(new Date(), 1), - }); + cookieStore.set({ + name: Cookies.MenuConfig, + value: JSON.stringify(value), + expires: addYears(new Date(), 1), + }); - return value; - }); + return value; + }); diff --git a/apps/app/src/app/(app)/[orgId]/components/OnboardingTracker.tsx b/apps/app/src/app/(app)/[orgId]/components/OnboardingTracker.tsx index ec715f7d4a..4a736ac029 100644 --- a/apps/app/src/app/(app)/[orgId]/components/OnboardingTracker.tsx +++ b/apps/app/src/app/(app)/[orgId]/components/OnboardingTracker.tsx @@ -3,254 +3,234 @@ import { useEffect, useState } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { LogoSpinner } from "@/components/logo-spinner"; -import { Onboarding } from "@comp/db/types"; +import type { Onboarding } from "@comp/db/types"; import { Alert, AlertDescription, AlertTitle } from "@comp/ui/alert"; import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, } from "@comp/ui/card"; import { useRealtimeRun } from "@trigger.dev/react-hooks"; import { AlertTriangle, Rocket, ShieldAlert, Zap } from "lucide-react"; const PROGRESS_MESSAGES = [ - "Learning about your company...", - "Creating Risks...", - "Creating Vendors...", - "Tailoring Policies...", + "Learning about your company...", + "Creating Risks...", + "Creating Vendors...", + "Tailoring Policies...", ]; const IN_PROGRESS_STATUSES = [ - "QUEUED", - "EXECUTING", - "WAITING_FOR_DEPLOY", - "REATTEMPTING", - "FROZEN", - "DELAYED", + "QUEUED", + "EXECUTING", + "WAITING_FOR_DEPLOY", + "REATTEMPTING", + "FROZEN", + "DELAYED", ]; const getFriendlyStatusName = (status: string): string => { - if (!status) return "Unknown"; - return status - .toLowerCase() - .replace(/_/g, " ") - .replace(/\b\w/g, (char) => char.toUpperCase()); + if (!status) return "Unknown"; + return status + .toLowerCase() + .replace(/_/g, " ") + .replace(/\b\w/g, (char) => char.toUpperCase()); }; export const OnboardingTracker = ({ - onboarding, - publicAccessToken, + onboarding, + publicAccessToken, }: { - onboarding: Onboarding; - publicAccessToken: string; + onboarding: Onboarding; + publicAccessToken: string; }) => { - const [currentMessageIndex, setCurrentMessageIndex] = useState(0); - const triggerJobId = onboarding.triggerJobId; - const { run, error } = useRealtimeRun(triggerJobId ?? undefined, { - accessToken: publicAccessToken, - }); + const [currentMessageIndex, setCurrentMessageIndex] = useState(0); + const triggerJobId = onboarding.triggerJobId; + const { run, error } = useRealtimeRun(triggerJobId ?? undefined, { + accessToken: publicAccessToken, + }); - useEffect(() => { - let interval: NodeJS.Timeout; - if (run && IN_PROGRESS_STATUSES.includes(run.status)) { - interval = setInterval(() => { - setCurrentMessageIndex( - (prevIndex) => (prevIndex + 1) % PROGRESS_MESSAGES.length, - ); - }, 4000); - } else { - setCurrentMessageIndex(0); // Reset when not in progress - } - return () => clearInterval(interval); - }, [run?.status]); + useEffect(() => { + let interval: NodeJS.Timeout; + if (run && IN_PROGRESS_STATUSES.includes(run.status)) { + interval = setInterval(() => { + setCurrentMessageIndex( + (prevIndex) => (prevIndex + 1) % PROGRESS_MESSAGES.length, + ); + }, 4000); + } else { + setCurrentMessageIndex(0); // Reset when not in progress + } + return () => clearInterval(interval); + }, [run?.status]); - if (error) { - return ( - - {" "} - {/* Use theme destructive color */} - - System Alert - {" "} - {/* Rely on Alert's variant styling */} - {error.message} - - ); - } + if (!triggerJobId) { + return ( + + + + Onboarding Status + + + Organization setup has not started yet. + + + +
+ {" "} + {/* Use theme warning color */} +
+

+ Awaiting Initiation +

+

+ No onboarding process has been started. +

+
+
+
+
+ ); + } - if (!triggerJobId) { - return ( - - - - Onboarding Status - - - Organization setup has not started yet. - - - -
- {" "} - {/* Use theme warning color */} -
-

- Awaiting Initiation -

-

- No onboarding process has been started. -

-
-
-
-
- ); - } + const renderStatusContent = () => { + if (!run && !error) { + return ( +
+ +
+

+ Initializing Status +

+

+ Checking the current onboarding status... +

+
+
+ ); + } + if (!run) { + return ( +
+ {" "} + {/* Use theme warning color */} +
+

+ Status Unavailable +

{" "} + {/* Use theme warning color */} +

+ Could not retrieve current onboarding status. +

+
+
+ ); + } - const renderStatusContent = () => { - if (!run && !error) { - return ( -
- -
-

- Initializing Status -

-

- Checking the current onboarding status... -

-
-
- ); - } - if (!run) { - return ( -
- {" "} - {/* Use theme warning color */} -
-

- Status Unavailable -

{" "} - {/* Use theme warning color */} -

- Could not retrieve current onboarding status. -

-
-
- ); - } + const friendlyStatus = getFriendlyStatusName(run.status); - const friendlyStatus = getFriendlyStatusName(run.status); + switch (run.status) { + case "WAITING_FOR_DEPLOY": + case "QUEUED": + case "EXECUTING": + case "REATTEMPTING": + case "FROZEN": + case "DELAYED": + return ( +
+ +
+ + + {PROGRESS_MESSAGES[currentMessageIndex]} + + +

+ We are setting up your organization. This may take a few + moments. +

+
+
+ ); + case "COMPLETED": + return ( +
+ +
+

+ Setup Complete +

+

+ Your organization is ready. +

+
+
+ ); + case "FAILED": + case "CANCELED": + case "CRASHED": + case "INTERRUPTED": + case "SYSTEM_FAILURE": + case "EXPIRED": + case "TIMED_OUT": { + const errorMessage = + run.error?.message || "An unexpected issue occurred."; + const truncatedMessage = + errorMessage.length > 100 + ? `${errorMessage.substring(0, 97)}...` + : errorMessage; + return ( +
+ {" "} +
+

+ Setup {friendlyStatus} +

+

+ {truncatedMessage} +

+
+
+ ); + } + default: { + const exhaustiveCheck: never = run.status as never; - switch (run.status) { - case "WAITING_FOR_DEPLOY": - case "QUEUED": - case "EXECUTING": - case "REATTEMPTING": - case "FROZEN": - case "DELAYED": - return ( -
- -
- - - {PROGRESS_MESSAGES[currentMessageIndex]} - - -

- We are setting up your organization. This may - take a few moments. -

-
-
- ); - case "COMPLETED": - return ( -
- -
-

- Setup Complete -

-

- Your organization is ready. -

-
-
- ); - case "FAILED": - case "CANCELED": - case "CRASHED": - case "INTERRUPTED": - case "SYSTEM_FAILURE": - case "EXPIRED": - case "TIMED_OUT": { - const errorMessage = - run.error?.message || "An unexpected issue occurred."; - const truncatedMessage = - errorMessage.length > 100 - ? `${errorMessage.substring(0, 97)}...` - : errorMessage; - return ( -
- {" "} -
-

- Setup{" "} - - {friendlyStatus} - -

-

- {truncatedMessage} -

-
-
- ); - } - default: { - const exhaustiveCheck: never = run.status as never; + return ( +
+ +
+

+ Unknown Status +

+

+ Received an unhandled status: {exhaustiveCheck} +

+
+
+ ); + } + } + }; - return ( -
- -
-

- Unknown Status -

-

- Received an unhandled status: {exhaustiveCheck} -

-
-
- ); - } - } - }; + if (run?.status === "COMPLETED") { + return null; + } - if (run?.status === "COMPLETED") { - return null; - } - - return ( - - -
{renderStatusContent()}
-
-
- ); + return ( + + +
{renderStatusContent()}
+
+
+ ); }; diff --git a/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/ControlDeleteDialog.tsx b/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/ControlDeleteDialog.tsx index 9827ee337b..e25ec88274 100644 --- a/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/ControlDeleteDialog.tsx +++ b/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/ControlDeleteDialog.tsx @@ -4,12 +4,12 @@ import { deleteControlAction } from "@/app/(app)/[orgId]/controls/[controlId]/ac import { Control } from "@comp/db/types"; import { Button } from "@comp/ui/button"; import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, } from "@comp/ui/dialog"; import { Form } from "@comp/ui/form"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -22,98 +22,98 @@ import { toast } from "sonner"; import { z } from "zod"; const formSchema = z.object({ - comment: z.string().optional(), + comment: z.string().optional(), }); type FormValues = z.infer; interface ControlDeleteDialogProps { - isOpen: boolean; - onClose: () => void; - control: Control; + isOpen: boolean; + onClose: () => void; + control: Control; } export function ControlDeleteDialog({ - isOpen, - onClose, - control, + isOpen, + onClose, + control, }: ControlDeleteDialogProps) { - const router = useRouter(); - const [isSubmitting, setIsSubmitting] = useState(false); + const router = useRouter(); + const [isSubmitting, setIsSubmitting] = useState(false); - const form = useForm({ - resolver: zodResolver(formSchema), - defaultValues: { - comment: "", - }, - }); + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + comment: "", + }, + }); - const deleteControl = useAction(deleteControlAction, { - onSuccess: () => { - toast.info("Control deleted! Redirecting to controls list..."); - onClose(); - router.push(`/${control.organizationId}/controls`); - }, - onError: () => { - toast.error("Failed to delete control."); - setIsSubmitting(false); - }, - }); + const deleteControl = useAction(deleteControlAction, { + onSuccess: () => { + toast.info("Control deleted! Redirecting to controls list..."); + onClose(); + router.push(`/${control.organizationId}/controls`); + }, + onError: () => { + toast.error("Failed to delete control."); + setIsSubmitting(false); + }, + }); - const handleSubmit = async (values: FormValues) => { - setIsSubmitting(true); - deleteControl.execute({ - id: control.id, - entityId: control.id, - }); - }; + const handleSubmit = async (values: FormValues) => { + setIsSubmitting(true); + deleteControl.execute({ + id: control.id, + entityId: control.id, + }); + }; - return ( - !open && onClose()}> - - - Delete Control - - Are you sure you want to delete this control? This - action cannot be undone. - - -
- - - - - -
- -
-
- ); + return ( + !open && onClose()}> + + + Delete Control + + Are you sure you want to delete this control? This action cannot be + undone. + + +
+ + + + + +
+ +
+
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/PoliciesTable.tsx b/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/PoliciesTable.tsx index 63eb432ea4..e75627c679 100644 --- a/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/PoliciesTable.tsx +++ b/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/PoliciesTable.tsx @@ -11,117 +11,106 @@ import { ColumnDef } from "@tanstack/react-table"; import { useMemo, useState } from "react"; interface PoliciesTableProps { - policies: Policy[]; - orgId: string; - controlId: string; + policies: Policy[]; + orgId: string; + controlId: string; } export function PoliciesTable({ - policies, - orgId, - controlId, + policies, + orgId, + controlId, }: PoliciesTableProps) { - const [searchTerm, setSearchTerm] = useState(""); + const [searchTerm, setSearchTerm] = useState(""); - const columns = useMemo[]>( - () => [ - { - accessorKey: "name", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const name = row.original.name; - return {name}; - }, - enableSorting: true, - sortingFn: (rowA, rowB) => { - const nameA = rowA.original.name || ""; - const nameB = rowB.original.name || ""; - return nameA.localeCompare(nameB); - }, - }, - { - accessorKey: "createdAt", - header: ({ column }) => ( - - ), - cell: ({ row }) => ( - - {new Date(row.original.createdAt).toLocaleDateString()} - - ), - enableSorting: true, - sortingFn: (rowA, rowB) => { - const dateA = new Date(rowA.original.createdAt); - const dateB = new Date(rowB.original.createdAt); - return dateA.getTime() - dateB.getTime(); - }, - }, - { - accessorKey: "status", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const rawStatus = row.original.status; - return ; - }, - }, - ], - [], - ); + const columns = useMemo[]>( + () => [ + { + accessorKey: "name", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const name = row.original.name; + return {name}; + }, + enableSorting: true, + sortingFn: (rowA, rowB) => { + const nameA = rowA.original.name || ""; + const nameB = rowB.original.name || ""; + return nameA.localeCompare(nameB); + }, + }, + { + accessorKey: "createdAt", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {new Date(row.original.createdAt).toLocaleDateString()} + ), + enableSorting: true, + sortingFn: (rowA, rowB) => { + const dateA = new Date(rowA.original.createdAt); + const dateB = new Date(rowB.original.createdAt); + return dateA.getTime() - dateB.getTime(); + }, + }, + { + accessorKey: "status", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const rawStatus = row.original.status; + return ; + }, + }, + ], + [], + ); - const filteredPolicies = useMemo(() => { - if (!searchTerm.trim()) return policies; + const filteredPolicies = useMemo(() => { + if (!searchTerm.trim()) return policies; - const searchLower = searchTerm.toLowerCase(); - return policies.filter( - (policy) => - (policy.name && policy.name.toLowerCase().includes(searchLower)) || - (policy.id && policy.id.toLowerCase().includes(searchLower)) - ); - }, [policies, searchTerm]); + const searchLower = searchTerm.toLowerCase(); + return policies.filter( + (policy) => + (policy.name && policy.name.toLowerCase().includes(searchLower)) || + (policy.id && policy.id.toLowerCase().includes(searchLower)), + ); + }, [policies, searchTerm]); - const table = useDataTable({ - data: filteredPolicies, - columns, - pageCount: 1, - shallow: false, - getRowId: (row) => row.id, - initialState: { - sorting: [{ id: "createdAt", desc: true }], - }, - tableId: "policiesTable", - }); + const table = useDataTable({ + data: filteredPolicies, + columns, + pageCount: 1, + shallow: false, + getRowId: (row) => row.id, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + }, + tableId: "policiesTable", + }); - return ( -
-
- setSearchTerm(e.target.value)} - className="max-w-sm" - leftIcon={} - /> -
- - row.id} - tableId={"policiesTable"} - /> -
- ); + return ( +
+
+ setSearchTerm(e.target.value)} + className="max-w-sm" + leftIcon={} + /> +
+ + row.id} + tableId={"policiesTable"} + /> +
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/RequirementsTable.tsx b/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/RequirementsTable.tsx index 6f856f8b88..272c713b67 100644 --- a/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/RequirementsTable.tsx +++ b/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/RequirementsTable.tsx @@ -4,10 +4,10 @@ import { DataTable } from "@/components/data-table/data-table"; import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"; import { useDataTable } from "@/hooks/use-data-table"; import type { - FrameworkEditorFramework, - FrameworkEditorRequirement, - FrameworkInstance, - RequirementMap, + FrameworkEditorFramework, + FrameworkEditorRequirement, + FrameworkInstance, + RequirementMap, } from "@comp/db/types"; import { Input } from "@comp/ui/input"; import { Icons } from "@comp/ui/icons"; @@ -15,128 +15,135 @@ import { ColumnDef } from "@tanstack/react-table"; import { useMemo, useState } from "react"; interface RequirementsTableProps { - requirements: (RequirementMap & { frameworkInstance: FrameworkInstance & { framework: FrameworkEditorFramework }, - requirement: FrameworkEditorRequirement })[]; - orgId: string; + requirements: (RequirementMap & { + frameworkInstance: FrameworkInstance & { + framework: FrameworkEditorFramework; + }; + requirement: FrameworkEditorRequirement; + })[]; + orgId: string; } export function RequirementsTable({ - requirements, - orgId, + requirements, + orgId, }: RequirementsTableProps) { - const [searchTerm, setSearchTerm] = useState(""); + const [searchTerm, setSearchTerm] = useState(""); - // Define columns for requirements table - const columns = useMemo< - ColumnDef[] - >( - () => [ - { - id: "reqName", - accessorKey: "requirement.name", - header: ({ column }) => ( - - ), + // Define columns for requirements table + const columns = useMemo< + ColumnDef< + RequirementMap & { + frameworkInstance: FrameworkInstance & { + framework: FrameworkEditorFramework; + }; + requirement: FrameworkEditorRequirement; + } + >[] + >( + () => [ + { + id: "reqName", + accessorKey: "requirement.name", + header: ({ column }) => ( + + ), - cell: ({ row }) => { - return ( - - {row.original.requirement.name} - - ); - }, - enableSorting: true, - sortingFn: (rowA, rowB, columnId) => { - const nameA = rowA.original.requirement.name || ""; - const nameB = rowB.original.requirement.name || ""; + cell: ({ row }) => { + return ( + + {row.original.requirement.name} + + ); + }, + enableSorting: true, + sortingFn: (rowA, rowB, columnId) => { + const nameA = rowA.original.requirement.name || ""; + const nameB = rowB.original.requirement.name || ""; - return nameA.localeCompare(nameB); - }, - }, - { - id: "reqDescription", - accessorKey: "requirement.description", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - return ( - - {row.original.requirement.description} - - ); - }, - enableSorting: true, - sortingFn: (rowA, rowB, columnId) => { - const descA = rowA.original.requirement.description || ""; - const descB = rowB.original.requirement.description || ""; + return nameA.localeCompare(nameB); + }, + }, + { + id: "reqDescription", + accessorKey: "requirement.description", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + return ( + + {row.original.requirement.description} + + ); + }, + enableSorting: true, + sortingFn: (rowA, rowB, columnId) => { + const descA = rowA.original.requirement.description || ""; + const descB = rowB.original.requirement.description || ""; - return descA.localeCompare(descB); - }, - }, - ], - [], - ); + return descA.localeCompare(descB); + }, + }, + ], + [], + ); - // Filter requirements data based on search term - const filteredRequirements = useMemo(() => { - if (!searchTerm.trim()) return requirements; + // Filter requirements data based on search term + const filteredRequirements = useMemo(() => { + if (!searchTerm.trim()) return requirements; - const searchLower = searchTerm.toLowerCase(); - return requirements.filter((req) => { - // Search in ID, name, and description from the nested requirement object - return ( - (req.requirement.id?.toLowerCase() || "").includes(searchLower) || - (req.requirement.name?.toLowerCase() || "").includes(searchLower) || - (req.requirement.description?.toLowerCase() || "").includes(searchLower) || - (req.requirement.identifier?.toLowerCase() || "").includes(searchLower) // Also search identifier - ); - }); - }, [requirements, searchTerm]); + const searchLower = searchTerm.toLowerCase(); + return requirements.filter((req) => { + // Search in ID, name, and description from the nested requirement object + return ( + (req.requirement.id?.toLowerCase() || "").includes(searchLower) || + (req.requirement.name?.toLowerCase() || "").includes(searchLower) || + (req.requirement.description?.toLowerCase() || "").includes( + searchLower, + ) || + (req.requirement.identifier?.toLowerCase() || "").includes(searchLower) // Also search identifier + ); + }); + }, [requirements, searchTerm]); - // Set up the requirements table - const table = useDataTable({ - data: filteredRequirements, - columns, - pageCount: 1, - shallow: false, - getRowId: (row) => row.id, - initialState: { - // No default sorting to avoid type issues - }, - tableId: "r", - clearOnDefault: true, - }); + // Set up the requirements table + const table = useDataTable({ + data: filteredRequirements, + columns, + pageCount: 1, + shallow: false, + getRowId: (row) => row.id, + initialState: { + // No default sorting to avoid type issues + }, + tableId: "r", + clearOnDefault: true, + }); - return ( -
-
- setSearchTerm(e.target.value)} - className="max-w-sm" - leftIcon={} - /> -
- - { - // This constructs the path to the specific requirement page - // row.requirementId is the FrameworkEditorRequirement.id (e.g. frk_rq_...) - // row.frameworkInstanceId is the ID of the FrameworkInstance - return `${row.frameworkInstanceId}/requirements/${row.requirementId}`; - }} - tableId={"r"} - /> -
- ); + return ( +
+
+ setSearchTerm(e.target.value)} + className="max-w-sm" + leftIcon={} + /> +
+ + { + // This constructs the path to the specific requirement page + // row.requirementId is the FrameworkEditorRequirement.id (e.g. frk_rq_...) + // row.frameworkInstanceId is the ID of the FrameworkInstance + return `${row.frameworkInstanceId}/requirements/${row.requirementId}`; + }} + tableId={"r"} + /> +
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/SingleControl.tsx b/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/SingleControl.tsx index 8b82a00a5e..0771786fac 100644 --- a/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/SingleControl.tsx +++ b/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/SingleControl.tsx @@ -2,21 +2,21 @@ import { StatusIndicator } from "@/components/status-indicator"; import type { - Control, - FrameworkEditorFramework, - FrameworkEditorRequirement, - FrameworkInstance, - Policy, - RequirementMap, - Task, + Control, + FrameworkEditorFramework, + FrameworkEditorRequirement, + FrameworkInstance, + Policy, + RequirementMap, + Task, } from "@comp/db/types"; import { Button } from "@comp/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@comp/ui/tabs"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, } from "@comp/ui/dropdown-menu"; import { MoreVertical, Trash2 } from "lucide-react"; import { useState } from "react"; @@ -30,148 +30,143 @@ import { SingleControlSkeleton } from "./SingleControlSkeleton"; import { TasksTable } from "./TasksTable"; interface SingleControlProps { - control: Control & { - requirementsMapped: (RequirementMap & { - frameworkInstance: FrameworkInstance & { - framework: FrameworkEditorFramework; - }; - requirement: FrameworkEditorRequirement; - })[]; - }; - controlProgress: ControlProgressResponse; - relatedPolicies: Policy[]; - relatedTasks: Task[]; + control: Control & { + requirementsMapped: (RequirementMap & { + frameworkInstance: FrameworkInstance & { + framework: FrameworkEditorFramework; + }; + requirement: FrameworkEditorRequirement; + })[]; + }; + controlProgress: ControlProgressResponse; + relatedPolicies: Policy[]; + relatedTasks: Task[]; } export function SingleControl({ - control, - controlProgress, - relatedPolicies, - relatedTasks, + control, + controlProgress, + relatedPolicies, + relatedTasks, }: SingleControlProps) { - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [dropdownOpen, setDropdownOpen] = useState(false); - const params = useParams<{ orgId: string; controlId: string }>(); - const orgIdFromParams = params.orgId; - const controlIdFromParams = params.controlId; + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [dropdownOpen, setDropdownOpen] = useState(false); + const params = useParams<{ orgId: string; controlId: string }>(); + const orgIdFromParams = params.orgId; + const controlIdFromParams = params.controlId; - const progressStatus = useMemo(() => { - if (!controlProgress) return "not_started"; - if (controlProgress.total === controlProgress.completed) - return "completed"; - if (controlProgress.completed > 0) return "in_progress"; + const progressStatus = useMemo(() => { + if (!controlProgress) return "not_started"; + if (controlProgress.total === controlProgress.completed) return "completed"; + if (controlProgress.completed > 0) return "in_progress"; - // Check if any task is not "todo" or any policy is not "draft" - const anyTaskInProgress = relatedTasks.some( - (task) => task.status !== "todo", - ); - const anyPolicyInProgress = relatedPolicies.some( - (policy) => policy.status !== "draft", - ); - if (anyTaskInProgress || anyPolicyInProgress) return "in_progress"; + // Check if any task is not "todo" or any policy is not "draft" + const anyTaskInProgress = relatedTasks.some( + (task) => task.status !== "todo", + ); + const anyPolicyInProgress = relatedPolicies.some( + (policy) => policy.status !== "draft", + ); + if (anyTaskInProgress || anyPolicyInProgress) return "in_progress"; - return "not_started"; - }, [controlProgress, relatedPolicies, relatedTasks]); + return "not_started"; + }, [controlProgress, relatedPolicies, relatedTasks]); - if (!control || !controlProgress) { - return ; - } + if (!control || !controlProgress) { + return ; + } - return ( -
- {/* Control Header */} -
-
-
-
-

{control.name}

- -
- {control.description && ( -

- {control.description} -

- )} -
- - - - - - { - setDropdownOpen(false); - setDeleteDialogOpen(true); - }} - className="text-destructive focus:text-destructive" - > - - Delete - - - -
-
+ return ( +
+ {/* Control Header */} +
+
+
+
+

{control.name}

+ +
+ {control.description && ( +

+ {control.description} +

+ )} +
+ + + + + + { + setDropdownOpen(false); + setDeleteDialogOpen(true); + }} + className="text-destructive focus:text-destructive" + > + + Delete + + + +
+
- {/* Tabbed Content */} - - - - Requirements - - {control.requirementsMapped.length} - - - - Policies - - {relatedPolicies.length} - - - - Tasks - - {relatedTasks.length} - - - - - - - - - - - - - - - - + {/* Tabbed Content */} + + + + Requirements + + {control.requirementsMapped.length} + + + + Policies + + {relatedPolicies.length} + + + + Tasks + + {relatedTasks.length} + + + - {/* Delete Dialog */} - setDeleteDialogOpen(false)} - control={control} - /> -
- ); + + + + + + + + + + + + + + {/* Delete Dialog */} + setDeleteDialogOpen(false)} + control={control} + /> +
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/SingleControlSkeleton.tsx b/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/SingleControlSkeleton.tsx index 8551284a0f..38f436c887 100644 --- a/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/SingleControlSkeleton.tsx +++ b/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/SingleControlSkeleton.tsx @@ -3,32 +3,32 @@ import { CardContent, CardHeader, CardTitle } from "@comp/ui/card"; import { Card } from "@comp/ui/card"; export const SingleControlSkeleton = () => { - return ( -
-
-
- - -
- - -
- - - - - Domain - - -
- - -
+ return ( +
+
+
+ + +
+ + +
+ + + + + Domain + + +
+ + +
-
-
-
-
-
- ); +
+
+
+
+
+ ); }; diff --git a/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/TasksTable.tsx b/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/TasksTable.tsx index 5ed410d335..048e52fe62 100644 --- a/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/TasksTable.tsx +++ b/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/TasksTable.tsx @@ -11,110 +11,103 @@ import { ColumnDef } from "@tanstack/react-table"; import { useMemo, useState } from "react"; interface TasksTableProps { - tasks: Task[]; - orgId: string; - controlId: string; + tasks: Task[]; + orgId: string; + controlId: string; } export function TasksTable({ tasks, orgId, controlId }: TasksTableProps) { - const [searchTerm, setSearchTerm] = useState(""); + const [searchTerm, setSearchTerm] = useState(""); - // Define columns for tasks table - const columns = useMemo[]>( - () => [ - { - accessorKey: "title", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const title = row.original.title; - return {title}; - }, - enableSorting: true, - sortingFn: (rowA, rowB, columnId) => { - const nameA = rowA.original.title || ""; - const nameB = rowB.original.title || ""; - return nameA.localeCompare(nameB); - }, - }, - { - accessorKey: "description", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const description = row.original.description; - return ( - - {description} - - ); - }, - }, - { - accessorKey: "status", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const rawStatus = row.original.status; + // Define columns for tasks table + const columns = useMemo[]>( + () => [ + { + accessorKey: "title", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const title = row.original.title; + return {title}; + }, + enableSorting: true, + sortingFn: (rowA, rowB, columnId) => { + const nameA = rowA.original.title || ""; + const nameB = rowB.original.title || ""; + return nameA.localeCompare(nameB); + }, + }, + { + accessorKey: "description", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const description = row.original.description; + return {description}; + }, + }, + { + accessorKey: "status", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const rawStatus = row.original.status; - // Pass the mapped status directly to StatusIndicator - return ; - }, - }, - ], - [], - ); + // Pass the mapped status directly to StatusIndicator + return ; + }, + }, + ], + [], + ); - // Filter tasks data based on search term - const filteredTasks = useMemo(() => { - if (!searchTerm.trim()) return tasks; + // Filter tasks data based on search term + const filteredTasks = useMemo(() => { + if (!searchTerm.trim()) return tasks; - const searchLower = searchTerm.toLowerCase(); - return tasks.filter( - (task) => - task.id.toLowerCase().includes(searchLower) || - task.title.toLowerCase().includes(searchLower) || - task.description.toLowerCase().includes(searchLower), - ); - }, [tasks, searchTerm]); + const searchLower = searchTerm.toLowerCase(); + return tasks.filter( + (task) => + task.id.toLowerCase().includes(searchLower) || + task.title.toLowerCase().includes(searchLower) || + task.description.toLowerCase().includes(searchLower), + ); + }, [tasks, searchTerm]); - // Set up the tasks table - const table = useDataTable({ - data: filteredTasks, - columns, - pageCount: 1, - shallow: false, - getRowId: (row) => row.id, - initialState: { - sorting: [{ id: "createdAt", desc: true }], - }, - tableId: "t", - }); + // Set up the tasks table + const table = useDataTable({ + data: filteredTasks, + columns, + pageCount: 1, + shallow: false, + getRowId: (row) => row.id, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + }, + tableId: "t", + }); - return ( -
-
- setSearchTerm(e.target.value)} - className="max-w-sm" - leftIcon={} - /> -
- - `/tasks/${row.id}`} - tableId={"t"} - /> -
- ); + return ( +
+
+ setSearchTerm(e.target.value)} + className="max-w-sm" + leftIcon={} + /> +
+ + `/tasks/${row.id}`} + tableId={"t"} + /> +
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/table/ControlRequirementsTable.tsx b/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/table/ControlRequirementsTable.tsx index 5d55cc7ced..178febcd76 100644 --- a/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/table/ControlRequirementsTable.tsx +++ b/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/table/ControlRequirementsTable.tsx @@ -1,9 +1,9 @@ "use client"; import { - flexRender, - getCoreRowModel, - useReactTable, + flexRender, + getCoreRowModel, + useReactTable, } from "@tanstack/react-table"; import type { Task, Policy, FrameworkEditorRequirement } from "@comp/db/types"; @@ -14,87 +14,78 @@ import { ControlRequirementsTableHeader } from "./ControlRequirementsTableHeader // Define the type that matches what we receive from the hook export type RequirementTableData = FrameworkEditorRequirement & { - policy: Policy | null; - task: Task | null; + policy: Policy | null; + task: Task | null; }; interface DataTableProps { - data: RequirementTableData[]; + data: RequirementTableData[]; } export function ControlRequirementsTable({ data }: DataTableProps) { - const router = useRouter(); - const { orgId } = useParams<{ orgId: string }>(); + const router = useRouter(); + const { orgId } = useParams<{ orgId: string }>(); - const table = useReactTable({ - data, - columns: ControlRequirementsTableColumns, - getCoreRowModel: getCoreRowModel(), - }); + const table = useReactTable({ + data, + columns: ControlRequirementsTableColumns, + getCoreRowModel: getCoreRowModel(), + }); - const onRowClick = (requirement: RequirementTableData) => { - switch (requirement.policy ? "policy" : "task") { - case "policy": - if (requirement.policy?.id) { - router.push( - `/${orgId}/policies/all/${requirement.policy.id}`, - ); - } - break; - case "task": - if (requirement.task?.id) { - router.push(`/${orgId}/tasks/${requirement.task.id}`); - } - break; - default: - break; - } - }; + const onRowClick = (requirement: RequirementTableData) => { + switch (requirement.policy ? "policy" : "task") { + case "policy": + if (requirement.policy?.id) { + router.push(`/${orgId}/policies/all/${requirement.policy.id}`); + } + break; + case "task": + if (requirement.task?.id) { + router.push(`/${orgId}/tasks/${requirement.task.id}`); + } + break; + default: + break; + } + }; - return ( -
-
- - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - onRowClick(row.original)} - > - {row.getVisibleCells().map((cell) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} - - ))} - - )) - ) : ( - - - No requirements found. - - - )} - -
-
-
- ); + return ( +
+
+ + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + onRowClick(row.original)} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + No requirements found. + + + )} + +
+
+
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/table/ControlRequirementsTableColumns.tsx b/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/table/ControlRequirementsTableColumns.tsx index 17bdbfd29a..9755f8effb 100644 --- a/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/table/ControlRequirementsTableColumns.tsx +++ b/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/table/ControlRequirementsTableColumns.tsx @@ -5,66 +5,59 @@ import { CheckCircle2, XCircle } from "lucide-react"; import type { RequirementTableData } from "./ControlRequirementsTable"; export const ControlRequirementsTableColumns: ColumnDef[] = - [ - { - id: "type", - accessorKey: "type", - header: "Type", - cell: ({ row }) => { - const requirement = row.original; - return requirement.policy - ? "policy" - : requirement.task - ? "task" - : ""; - }, - size: 100, - }, - { - id: "description", - accessorKey: "description", - header: "Description", - size: 1000, - cell: ({ row }) => { - const description = row.original.description || ""; // Default to empty string if null - const maxLength = 300; // Increased character limit - const displayText = - description.length > maxLength - ? `${description.substring(0, maxLength)}...` - : description; + [ + { + id: "type", + accessorKey: "type", + header: "Type", + cell: ({ row }) => { + const requirement = row.original; + return requirement.policy ? "policy" : requirement.task ? "task" : ""; + }, + size: 100, + }, + { + id: "description", + accessorKey: "description", + header: "Description", + size: 1000, + cell: ({ row }) => { + const description = row.original.description || ""; // Default to empty string if null + const maxLength = 300; // Increased character limit + const displayText = + description.length > maxLength + ? `${description.substring(0, maxLength)}...` + : description; - return ( -
- {displayText} -
- ); - }, - }, - { - id: "status", - accessorKey: "status", - header: "Status", - size: 80, - cell: ({ row }) => { - const requirement = row.original; - const isCompleted = requirement.policy - ? requirement.policy?.status === "published" - : requirement.task - ? requirement.task?.status === "done" - : false; + return ( +
+ {displayText} +
+ ); + }, + }, + { + id: "status", + accessorKey: "status", + header: "Status", + size: 80, + cell: ({ row }) => { + const requirement = row.original; + const isCompleted = requirement.policy + ? requirement.policy?.status === "published" + : requirement.task + ? requirement.task?.status === "done" + : false; - return ( -
- {isCompleted ? ( - - ) : ( - - )} -
- ); - }, - }, - ]; + return ( +
+ {isCompleted ? ( + + ) : ( + + )} +
+ ); + }, + }, + ]; diff --git a/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/table/ControlRequirementsTableHeader.tsx b/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/table/ControlRequirementsTableHeader.tsx index 6f10fd4fdd..ac9ee53088 100644 --- a/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/table/ControlRequirementsTableHeader.tsx +++ b/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/table/ControlRequirementsTableHeader.tsx @@ -5,37 +5,37 @@ import type { Table } from "@tanstack/react-table"; import type { RequirementTableData } from "./ControlRequirementsTable"; type Props = { - table: Table; - loading?: boolean; + table: Table; + loading?: boolean; }; export function ControlRequirementsTableHeader({ table, loading }: Props) { - const isVisible = (id: string) => - loading || - table - .getAllLeafColumns() - .find((col) => col.id === id) - ?.getIsVisible(); + const isVisible = (id: string) => + loading || + table + .getAllLeafColumns() + .find((col) => col.id === id) + ?.getIsVisible(); - return ( - - - {isVisible("type") && ( - - Type - - )} - {isVisible("description") && ( - - Description - - )} - {isVisible("status") && ( - - Status - - )} - - - ); + return ( + + + {isVisible("type") && ( + + Type + + )} + {isVisible("description") && ( + + Description + + )} + {isVisible("status") && ( + + Status + + )} + + + ); } diff --git a/apps/app/src/app/(app)/[orgId]/controls/[controlId]/data/getControl.ts b/apps/app/src/app/(app)/[orgId]/controls/[controlId]/data/getControl.ts index 0cac537586..febeaf84f0 100644 --- a/apps/app/src/app/(app)/[orgId]/controls/[controlId]/data/getControl.ts +++ b/apps/app/src/app/(app)/[orgId]/controls/[controlId]/data/getControl.ts @@ -3,41 +3,41 @@ import { db } from "@comp/db"; import { headers } from "next/headers"; export const getControl = async (id: string) => { - const session = await auth.api.getSession({ - headers: await headers(), - }); + const session = await auth.api.getSession({ + headers: await headers(), + }); - if (!session) { - return { - error: "Unauthorized", - }; - } + if (!session) { + return { + error: "Unauthorized", + }; + } - if (!session.session.activeOrganizationId) { - return { - error: "Unauthorized", - }; - } + if (!session.session.activeOrganizationId) { + return { + error: "Unauthorized", + }; + } - const control = await db.control.findUnique({ - where: { - organizationId: session.session.activeOrganizationId, - id, - }, - include: { - requirementsMapped: { - include: { - frameworkInstance: { - include: { - framework: true, - }, - }, - requirement: true, - }, - }, - tasks: true, - }, - }); + const control = await db.control.findUnique({ + where: { + organizationId: session.session.activeOrganizationId, + id, + }, + include: { + requirementsMapped: { + include: { + frameworkInstance: { + include: { + framework: true, + }, + }, + requirement: true, + }, + }, + tasks: true, + }, + }); - return control; + return control; }; diff --git a/apps/app/src/app/(app)/[orgId]/controls/[controlId]/data/getOrganizationControlProgress.ts b/apps/app/src/app/(app)/[orgId]/controls/[controlId]/data/getOrganizationControlProgress.ts index 65dcdc26fe..c435630798 100644 --- a/apps/app/src/app/(app)/[orgId]/controls/[controlId]/data/getOrganizationControlProgress.ts +++ b/apps/app/src/app/(app)/[orgId]/controls/[controlId]/data/getOrganizationControlProgress.ts @@ -5,114 +5,114 @@ import { db } from "@comp/db"; import { headers } from "next/headers"; export interface ControlProgressResponse { - total: number; - completed: number; - progress: number; - byType: { - [key: string]: { - total: number; - completed: number; - }; - }; + total: number; + completed: number; + progress: number; + byType: { + [key: string]: { + total: number; + completed: number; + }; + }; } export const getOrganizationControlProgress = async (controlId: string) => { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - if (!session) { - return { - error: "Unauthorized", - }; - } - - const orgId = session.session.activeOrganizationId; - - if (!orgId) { - return { - error: "Unauthorized", - }; - } - - // Get the control with its policies and tasks - const control = await db.control.findUnique({ - where: { - id: controlId, - }, - include: { - policies: true, - tasks: true, - }, - }); - - if (!control) { - return { - error: "Control not found", - }; - } - - const policies = control.policies || []; - const tasks = control.tasks || []; - const progress: ControlProgressResponse = { - total: policies.length + tasks.length, - completed: 0, - progress: 0, - byType: {}, - }; - - // Process policies - for (const policy of policies) { - const policyTypeKey = "policy"; - // Initialize type counters if not exists - if (!progress.byType[policyTypeKey]) { - progress.byType[policyTypeKey] = { - total: 0, - completed: 0, - }; - } - - progress.byType[policyTypeKey].total++; - - // Check completion based on policy status - const isCompleted = policy.status === "published"; - - if (isCompleted) { - progress.completed++; - progress.byType[policyTypeKey].completed++; - } - } - - // Process tasks - for (const task of tasks) { - const taskTypeKey = "task"; - // Initialize type counters if not exists - if (!progress.byType[taskTypeKey]) { - progress.byType[taskTypeKey] = { - total: 0, - completed: 0, - }; - } - - progress.byType[taskTypeKey].total++; - - const isCompleted = task.status === "done"; - - if (isCompleted) { - progress.completed++; - progress.byType[taskTypeKey].completed++; - } - } - - // Calculate overall progress percentage - progress.progress = - progress.total > 0 - ? Math.round((progress.completed / progress.total) * 100) - : 0; - - return { - data: { - progress, - }, - }; + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session) { + return { + error: "Unauthorized", + }; + } + + const orgId = session.session.activeOrganizationId; + + if (!orgId) { + return { + error: "Unauthorized", + }; + } + + // Get the control with its policies and tasks + const control = await db.control.findUnique({ + where: { + id: controlId, + }, + include: { + policies: true, + tasks: true, + }, + }); + + if (!control) { + return { + error: "Control not found", + }; + } + + const policies = control.policies || []; + const tasks = control.tasks || []; + const progress: ControlProgressResponse = { + total: policies.length + tasks.length, + completed: 0, + progress: 0, + byType: {}, + }; + + // Process policies + for (const policy of policies) { + const policyTypeKey = "policy"; + // Initialize type counters if not exists + if (!progress.byType[policyTypeKey]) { + progress.byType[policyTypeKey] = { + total: 0, + completed: 0, + }; + } + + progress.byType[policyTypeKey].total++; + + // Check completion based on policy status + const isCompleted = policy.status === "published"; + + if (isCompleted) { + progress.completed++; + progress.byType[policyTypeKey].completed++; + } + } + + // Process tasks + for (const task of tasks) { + const taskTypeKey = "task"; + // Initialize type counters if not exists + if (!progress.byType[taskTypeKey]) { + progress.byType[taskTypeKey] = { + total: 0, + completed: 0, + }; + } + + progress.byType[taskTypeKey].total++; + + const isCompleted = task.status === "done"; + + if (isCompleted) { + progress.completed++; + progress.byType[taskTypeKey].completed++; + } + } + + // Calculate overall progress percentage + progress.progress = + progress.total > 0 + ? Math.round((progress.completed / progress.total) * 100) + : 0; + + return { + data: { + progress, + }, + }; }; diff --git a/apps/app/src/app/(app)/[orgId]/controls/[controlId]/data/getRelatedPolicies.ts b/apps/app/src/app/(app)/[orgId]/controls/[controlId]/data/getRelatedPolicies.ts index f1ea6bb05e..070382f71b 100644 --- a/apps/app/src/app/(app)/[orgId]/controls/[controlId]/data/getRelatedPolicies.ts +++ b/apps/app/src/app/(app)/[orgId]/controls/[controlId]/data/getRelatedPolicies.ts @@ -6,41 +6,41 @@ import { Policy } from "@comp/db/types"; import { headers } from "next/headers"; interface GetRelatedPoliciesParams { - organizationId: string; - controlId: string; + organizationId: string; + controlId: string; } export const getRelatedPolicies = async ({ - organizationId, - controlId, + organizationId, + controlId, }: GetRelatedPoliciesParams): Promise => { - try { - const session = await auth.api.getSession({ - headers: await headers(), - }); + try { + const session = await auth.api.getSession({ + headers: await headers(), + }); - if (!session || !session.session.activeOrganizationId) { - return []; - } + if (!session || !session.session.activeOrganizationId) { + return []; + } - // Fetch the control with its policies - const control = await db.control.findUnique({ - where: { - id: controlId, - organizationId: organizationId, - }, - include: { - policies: true, - }, - }); + // Fetch the control with its policies + const control = await db.control.findUnique({ + where: { + id: controlId, + organizationId: organizationId, + }, + include: { + policies: true, + }, + }); - if (!control || !control.policies) { - return []; - } + if (!control || !control.policies) { + return []; + } - return control.policies || []; - } catch (error) { - console.error("Error fetching Linked Policies:", error); - return []; - } + return control.policies || []; + } catch (error) { + console.error("Error fetching Linked Policies:", error); + return []; + } }; diff --git a/apps/app/src/app/(app)/[orgId]/controls/[controlId]/loading.tsx b/apps/app/src/app/(app)/[orgId]/controls/[controlId]/loading.tsx index 1a0ea954fc..5ab08b08f5 100644 --- a/apps/app/src/app/(app)/[orgId]/controls/[controlId]/loading.tsx +++ b/apps/app/src/app/(app)/[orgId]/controls/[controlId]/loading.tsx @@ -1,105 +1,97 @@ import { Card, CardContent, CardHeader, CardTitle } from "@comp/ui/card"; import { Skeleton } from "@comp/ui/skeleton"; import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, } from "@comp/ui/table"; export default function Loading() { - return ( -
-
- {/* Control Info Cards */} -
- {/* Control Details Card */} - - - - - - - - - - - - + return ( +
+
+ {/* Control Info Cards */} +
+ {/* Control Details Card */} + + + + + + + + + + + + - {/* Domain Card */} - - - - - - - - - - -
+ {/* Domain Card */} + + + + + + + + + + +
- {/* Requirements Table */} -
-
-
- - - - - - - - - - - - - - - - {["type", "description", "status"].map( - (requirementType) => ( - - - - - - - - - - - - ), - )} - -
-
-
-
-
-
- ); + {/* Requirements Table */} +
+
+
+ + + + + + + + + + + + + + + + {["type", "description", "status"].map((requirementType) => ( + + + + + + + + + + + + ))} + +
+
+
+
+
+
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/controls/[controlId]/page.tsx b/apps/app/src/app/(app)/[orgId]/controls/[controlId]/page.tsx index 9b45915d94..507bef9836 100644 --- a/apps/app/src/app/(app)/[orgId]/controls/[controlId]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/controls/[controlId]/page.tsx @@ -9,63 +9,63 @@ import { getOrganizationControlProgress } from "./data/getOrganizationControlPro import { getRelatedPolicies } from "./data/getRelatedPolicies"; interface ControlPageProps { - params: { - controlId: string; - orgId: string; - locale: string; - }; + params: { + controlId: string; + orgId: string; + locale: string; + }; } export default async function ControlPage({ params }: ControlPageProps) { - // Await params before using them - const { controlId, orgId, locale } = await Promise.resolve(params); + // Await params before using them + const { controlId, orgId, locale } = await Promise.resolve(params); - const session = await auth.api.getSession({ - headers: await headers(), - }); + const session = await auth.api.getSession({ + headers: await headers(), + }); - if (!session?.session.activeOrganizationId) { - redirect("/"); - } + if (!session?.session.activeOrganizationId) { + redirect("/"); + } - const control = await getControl(controlId); + const control = await getControl(controlId); - // If we get an error or no result, redirect - if (!control || "error" in control) { - redirect("/"); - } + // If we get an error or no result, redirect + if (!control || "error" in control) { + redirect("/"); + } - const organizationControlProgressResult = - await getOrganizationControlProgress(controlId); + const organizationControlProgressResult = + await getOrganizationControlProgress(controlId); - // Extract the progress data from the result or create default data if there's an error - const controlProgress: ControlProgressResponse = ("data" in - (organizationControlProgressResult || {}) && - organizationControlProgressResult?.data?.progress) || { - total: 0, - completed: 0, - progress: 0, - byType: {}, - }; + // Extract the progress data from the result or create default data if there's an error + const controlProgress: ControlProgressResponse = ("data" in + (organizationControlProgressResult || {}) && + organizationControlProgressResult?.data?.progress) || { + total: 0, + completed: 0, + progress: 0, + byType: {}, + }; - const relatedPolicies = await getRelatedPolicies({ - organizationId: orgId, - controlId: controlId, - }); + const relatedPolicies = await getRelatedPolicies({ + organizationId: orgId, + controlId: controlId, + }); - return ( - - - - ); + return ( + + + + ); } diff --git a/apps/app/src/app/(app)/[orgId]/controls/components/controls-table-columns.tsx b/apps/app/src/app/(app)/[orgId]/controls/components/controls-table-columns.tsx index b7b52b4b83..c1adafc559 100644 --- a/apps/app/src/app/(app)/[orgId]/controls/components/controls-table-columns.tsx +++ b/apps/app/src/app/(app)/[orgId]/controls/components/controls-table-columns.tsx @@ -8,54 +8,54 @@ import { ControlWithRelations } from "../data/queries"; import { getControlStatus } from "../lib/utils"; export function getControlColumns(): ColumnDef[] { - return [ - { - id: "name", - accessorKey: "name", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - return ( -
- - {row.getValue("name")} - -
- ); - }, - meta: { - label: "Control Name", - placeholder: "Search for a control...", - variant: "text", - }, - enableColumnFilter: true, - filterFn: (row, id, value) => { - return value.length === 0 - ? true - : String(row.getValue(id)) - .toLowerCase() - .includes(String(value).toLowerCase()); - }, - }, - { - id: "status", - accessorKey: "", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const control = row.original; - const status = getControlStatus(control); + return [ + { + id: "name", + accessorKey: "name", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + return ( +
+ + {row.getValue("name")} + +
+ ); + }, + meta: { + label: "Control Name", + placeholder: "Search for a control...", + variant: "text", + }, + enableColumnFilter: true, + filterFn: (row, id, value) => { + return value.length === 0 + ? true + : String(row.getValue(id)) + .toLowerCase() + .includes(String(value).toLowerCase()); + }, + }, + { + id: "status", + accessorKey: "", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const control = row.original; + const status = getControlStatus(control); - return ; - }, - meta: { - label: "Status", - placeholder: "Search status...", - variant: "text", - }, - enableSorting: false, - }, - ]; + return ; + }, + meta: { + label: "Status", + placeholder: "Search status...", + variant: "text", + }, + enableSorting: false, + }, + ]; } diff --git a/apps/app/src/app/(app)/[orgId]/controls/components/controls-table.tsx b/apps/app/src/app/(app)/[orgId]/controls/components/controls-table.tsx index 370bcce07f..9e12df1f60 100644 --- a/apps/app/src/app/(app)/[orgId]/controls/components/controls-table.tsx +++ b/apps/app/src/app/(app)/[orgId]/controls/components/controls-table.tsx @@ -12,38 +12,39 @@ import { ControlWithRelations, getControls } from "../data/queries"; import { getControlColumns } from "./controls-table-columns"; interface ControlsTableProps { - promises: Promise<[{ data: ControlWithRelations[]; pageCount: number }]>; + promises: Promise<[{ data: ControlWithRelations[]; pageCount: number }]>; } export function ControlsTable({ promises }: ControlsTableProps) { - const [{ data, pageCount }] = React.use(promises); - const { orgId } = useParams(); - const columns = React.useMemo(() => getControlColumns(), []); - const [filteredData, setFilteredData] = React.useState(data); + const [{ data, pageCount }] = React.use(promises); + const { orgId } = useParams(); + const columns = React.useMemo(() => getControlColumns(), []); + const [filteredData, setFilteredData] = + React.useState(data); - // For client-side filtering, we don't need to apply server-side filtering - const { table } = useDataTable({ - data: filteredData, - columns, - pageCount, - initialState: { - sorting: [{ id: "name", desc: true }], - }, - getRowId: (row) => row.id, - shallow: false, - clearOnDefault: true, - }); + // For client-side filtering, we don't need to apply server-side filtering + const { table } = useDataTable({ + data: filteredData, + columns, + pageCount, + initialState: { + sorting: [{ id: "name", desc: true }], + }, + getRowId: (row) => row.id, + shallow: false, + clearOnDefault: true, + }); - return ( - <> - row.id} - rowClickBasePath={`/${orgId}/controls`} - > - - - - - ); + return ( + <> + row.id} + rowClickBasePath={`/${orgId}/controls`} + > + + + + + ); } diff --git a/apps/app/src/app/(app)/[orgId]/controls/data/queries.ts b/apps/app/src/app/(app)/[orgId]/controls/data/queries.ts index aa54d2505f..6b9c9a3a5a 100644 --- a/apps/app/src/app/(app)/[orgId]/controls/data/queries.ts +++ b/apps/app/src/app/(app)/[orgId]/controls/data/queries.ts @@ -8,82 +8,82 @@ import { headers } from "next/headers"; import type { GetControlSchema } from "./validations"; const controlInclude = { - policies: { - select: { - status: true, - id: true, - name: true, - }, - }, - requirementsMapped: { - include: { - frameworkInstance: { - include: { - framework: true, - }, - }, - requirement: { - select: { - name: true, - identifier: true, - }, - }, - }, - }, + policies: { + select: { + status: true, + id: true, + name: true, + }, + }, + requirementsMapped: { + include: { + frameworkInstance: { + include: { + framework: true, + }, + }, + requirement: { + select: { + name: true, + identifier: true, + }, + }, + }, + }, } satisfies Prisma.ControlInclude; export type ControlWithRelations = Prisma.ControlGetPayload<{ - include: typeof controlInclude; + include: typeof controlInclude; }>; export async function getControls( - input: GetControlSchema, + input: GetControlSchema, ): Promise<{ data: ControlWithRelations[]; pageCount: number }> { -// cache wrapper already handled: ensure it stays removed or remove if re-introduced - try { - const session = await auth.api.getSession({ - headers: await headers(), - }); - const organizationId = session?.session.activeOrganizationId; + // cache wrapper already handled: ensure it stays removed or remove if re-introduced + try { + const session = await auth.api.getSession({ + headers: await headers(), + }); + const organizationId = session?.session.activeOrganizationId; - if (!organizationId) { - throw new Error("Organization not found"); - } + if (!organizationId) { + throw new Error("Organization not found"); + } - const orderBy = input.sort.map((sort) => ({ - [sort.id]: sort.desc ? "desc" : "asc", - })); + const orderBy = input.sort.map((sort) => ({ + [sort.id]: sort.desc ? "desc" : "asc", + })); - const where: Prisma.ControlWhereInput = { - organizationId, - ...(input.name && { - name: { - contains: input.name, - mode: Prisma.QueryMode.insensitive, - }, - }), - ...(input.lastUpdated.length > 0 && { - lastUpdated: { - in: input.lastUpdated, - }, - }), - }; + const where: Prisma.ControlWhereInput = { + organizationId, + ...(input.name && { + name: { + contains: input.name, + mode: Prisma.QueryMode.insensitive, + }, + }), + ...(input.lastUpdated.length > 0 && { + lastUpdated: { + in: input.lastUpdated, + }, + }), + }; - const controls = await db.control.findMany({ - where, - orderBy: orderBy.length > 0 ? orderBy : [{ name: "asc" }], - skip: (input.page - 1) * input.perPage, - take: input.perPage, - include: controlInclude, - }); + const controls = await db.control.findMany({ + where, + orderBy: orderBy.length > 0 ? orderBy : [{ name: "asc" }], + skip: (input.page - 1) * input.perPage, + take: input.perPage, + include: controlInclude, + }); - const total = await db.control.count({ - where, - }); + const total = await db.control.count({ + where, + }); - const pageCount = Math.ceil(total / input.perPage); - return { data: controls, pageCount }; - } catch (_err) { - return { data: [], pageCount: 0 }; - } + const pageCount = Math.ceil(total / input.perPage); + return { data: controls, pageCount }; + } catch (_err) { + return { data: [], pageCount: 0 }; + } } diff --git a/apps/app/src/app/(app)/[orgId]/controls/data/validations.ts b/apps/app/src/app/(app)/[orgId]/controls/data/validations.ts index 14065e79a2..7b3abf80fa 100644 --- a/apps/app/src/app/(app)/[orgId]/controls/data/validations.ts +++ b/apps/app/src/app/(app)/[orgId]/controls/data/validations.ts @@ -1,27 +1,27 @@ import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"; import { Control } from "@comp/db/types"; import { - createSearchParamsCache, - parseAsArrayOf, - parseAsInteger, - parseAsString, - parseAsStringEnum, + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, } from "nuqs/server"; import * as z from "zod"; export const searchParamsCache = createSearchParamsCache({ - page: parseAsInteger.withDefault(1), - perPage: parseAsInteger.withDefault(50), - sort: getSortingStateParser().withDefault([ - { id: "name", desc: true }, - ]), - name: parseAsString.withDefault(""), - lastUpdated: parseAsArrayOf(z.coerce.date()).withDefault([]), - // advanced filter - filters: getFiltersStateParser().withDefault([]), - joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(50), + sort: getSortingStateParser().withDefault([ + { id: "name", desc: true }, + ]), + name: parseAsString.withDefault(""), + lastUpdated: parseAsArrayOf(z.coerce.date()).withDefault([]), + // advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), }); export type GetControlSchema = Awaited< - ReturnType + ReturnType >; diff --git a/apps/app/src/app/(app)/[orgId]/controls/layout.tsx b/apps/app/src/app/(app)/[orgId]/controls/layout.tsx index 05a2a317bb..6911ac6f07 100644 --- a/apps/app/src/app/(app)/[orgId]/controls/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/controls/layout.tsx @@ -1,11 +1,11 @@ export default async function Layout({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }) { - return ( -
-
{children}
-
- ); + return ( +
+
{children}
+
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/controls/lib/utils.ts b/apps/app/src/app/(app)/[orgId]/controls/lib/utils.ts index da2fc5b2c0..b3d726d4b9 100644 --- a/apps/app/src/app/(app)/[orgId]/controls/lib/utils.ts +++ b/apps/app/src/app/(app)/[orgId]/controls/lib/utils.ts @@ -2,27 +2,27 @@ import { StatusType } from "@/components/status-indicator"; import type { ControlWithRelations } from "../data/queries"; export function getControlStatus(control: ControlWithRelations): StatusType { - const policies = control.policies || []; + const policies = control.policies || []; - if (!policies.length) { - return "not_started"; - } + if (!policies.length) { + return "not_started"; + } - const hasUnpublishedPolicies = policies.some( - (policy) => policy.status !== "published", - ); + const hasUnpublishedPolicies = policies.some( + (policy) => policy.status !== "published", + ); - const allPoliciesAreDraft = policies.every( - (policy) => policy.status === "draft", - ); + const allPoliciesAreDraft = policies.every( + (policy) => policy.status === "draft", + ); - if (allPoliciesAreDraft) { - return "not_started"; - } + if (allPoliciesAreDraft) { + return "not_started"; + } - if (hasUnpublishedPolicies) { - return "in_progress"; - } + if (hasUnpublishedPolicies) { + return "in_progress"; + } - return "completed"; + return "completed"; } diff --git a/apps/app/src/app/(app)/[orgId]/controls/loading.tsx b/apps/app/src/app/(app)/[orgId]/controls/loading.tsx index e46c807a99..9636e0b23a 100644 --- a/apps/app/src/app/(app)/[orgId]/controls/loading.tsx +++ b/apps/app/src/app/(app)/[orgId]/controls/loading.tsx @@ -2,16 +2,16 @@ import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; import { Suspense } from "react"; export default function Loading() { - return ( - - } - /> - ); + return ( + + } + /> + ); } diff --git a/apps/app/src/app/(app)/[orgId]/controls/page.tsx b/apps/app/src/app/(app)/[orgId]/controls/page.tsx index a3c36a4af0..b4a84c6b71 100644 --- a/apps/app/src/app/(app)/[orgId]/controls/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/controls/page.tsx @@ -7,28 +7,24 @@ import { getControls } from "./data/queries"; import { searchParamsCache } from "./data/validations"; interface ControlTableProps { - searchParams: Promise; + searchParams: Promise; } -export default async function ControlsPage({ - ...props -}: ControlTableProps) { - const searchParams = await props.searchParams; - const search = searchParamsCache.parse(searchParams); - const validFilters = getValidFilters(search.filters); +export default async function ControlsPage({ ...props }: ControlTableProps) { + const searchParams = await props.searchParams; + const search = searchParamsCache.parse(searchParams); + const validFilters = getValidFilters(search.filters); - const promises = Promise.all([ - getControls({ - ...search, - filters: validFilters, - }), - ]); + const promises = Promise.all([ + getControls({ + ...search, + filters: validFilters, + }), + ]); - return ( - - - - ); + return ( + + + + ); } diff --git a/apps/app/src/app/(app)/[orgId]/error.tsx b/apps/app/src/app/(app)/[orgId]/error.tsx index 7ab7c00280..6435c9443f 100644 --- a/apps/app/src/app/(app)/[orgId]/error.tsx +++ b/apps/app/src/app/(app)/[orgId]/error.tsx @@ -5,37 +5,37 @@ import Link from "next/link"; import { useEffect } from "react"; export default function ErrorPage({ - reset, - error, -}: { reset: () => void; error: Error & { digest?: string } }) { - useEffect(() => { - console.error( - "app/(app)/(dashboard)/[orgId]/error.tsx", - error, - ); - }, [error]); + reset, + error, +}: { + reset: () => void; + error: Error & { digest?: string }; +}) { + useEffect(() => { + console.error("app/(app)/(dashboard)/[orgId]/error.tsx", error); + }, [error]); - return ( -
-
-
-

Something went wrong

-

- An unexpected error has occurred. Please try again -
or contact support if the issue persists. -

-
+ return ( +
+
+
+

Something went wrong

+

+ An unexpected error has occurred. Please try again +
or contact support if the issue persists. +

+
-
- +
+ - - - -
-
-
- ); + + + +
+
+
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/actions/delete-framework.ts b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/actions/delete-framework.ts index 4e0d2bd995..a7015ca8ee 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/actions/delete-framework.ts +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/actions/delete-framework.ts @@ -6,63 +6,63 @@ import { z } from "zod"; import { authActionClient } from "@/actions/safe-action"; const deleteFrameworkSchema = z.object({ - id: z.string(), - entityId: z.string(), + id: z.string(), + entityId: z.string(), }); export const deleteFrameworkAction = authActionClient - .schema(deleteFrameworkSchema) - .metadata({ - name: "delete-framework", - track: { - event: "delete-framework", - description: "Delete Framework Instance", - channel: "server", - }, - }) - .action(async ({ parsedInput, ctx }) => { - const { id } = parsedInput; - const { activeOrganizationId } = ctx.session; + .schema(deleteFrameworkSchema) + .metadata({ + name: "delete-framework", + track: { + event: "delete-framework", + description: "Delete Framework Instance", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { id } = parsedInput; + const { activeOrganizationId } = ctx.session; - if (!activeOrganizationId) { - return { - success: false, - error: "Not authorized", - }; - } + if (!activeOrganizationId) { + return { + success: false, + error: "Not authorized", + }; + } - try { - const frameworkInstance = await db.frameworkInstance.findUnique({ - where: { - id, - organizationId: activeOrganizationId, - }, - }); + try { + const frameworkInstance = await db.frameworkInstance.findUnique({ + where: { + id, + organizationId: activeOrganizationId, + }, + }); - if (!frameworkInstance) { - return { - success: false, - error: "Framework instance not found", - }; - } + if (!frameworkInstance) { + return { + success: false, + error: "Framework instance not found", + }; + } - // Delete the framework instance - await db.frameworkInstance.delete({ - where: { id }, - }); + // Delete the framework instance + await db.frameworkInstance.delete({ + where: { id }, + }); - // Revalidate paths to update UI - revalidatePath(`/${activeOrganizationId}/frameworks`); - revalidateTag("frameworks"); + // Revalidate paths to update UI + revalidatePath(`/${activeOrganizationId}/frameworks`); + revalidateTag("frameworks"); - return { - success: true, - }; - } catch (error) { - console.error(error); - return { - success: false, - error: "Failed to delete framework instance", - }; - } - }); + return { + success: true, + }; + } catch (error) { + console.error(error); + return { + success: false, + error: "Failed to delete framework instance", + }; + } + }); diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkDeleteDialog.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkDeleteDialog.tsx index fb467c65b2..fde64fce2c 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkDeleteDialog.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkDeleteDialog.tsx @@ -3,12 +3,12 @@ import { deleteFrameworkAction } from "../actions/delete-framework"; import { Button } from "@comp/ui/button"; import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, } from "@comp/ui/dialog"; import { Form } from "@comp/ui/form"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -22,98 +22,98 @@ import { z } from "zod"; import { FrameworkInstanceWithControls } from "../../types"; const formSchema = z.object({ - comment: z.string().optional(), + comment: z.string().optional(), }); type FormValues = z.infer; interface FrameworkDeleteDialogProps { - isOpen: boolean; - onClose: () => void; - frameworkInstance: FrameworkInstanceWithControls; + isOpen: boolean; + onClose: () => void; + frameworkInstance: FrameworkInstanceWithControls; } export function FrameworkDeleteDialog({ - isOpen, - onClose, - frameworkInstance, + isOpen, + onClose, + frameworkInstance, }: FrameworkDeleteDialogProps) { - const router = useRouter(); - const [isSubmitting, setIsSubmitting] = useState(false); + const router = useRouter(); + const [isSubmitting, setIsSubmitting] = useState(false); - const form = useForm({ - resolver: zodResolver(formSchema), - defaultValues: { - comment: "", - }, - }); + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + comment: "", + }, + }); - const deleteFramework = useAction(deleteFrameworkAction, { - onSuccess: () => { - toast.info("Framework deleted! Redirecting to frameworks list..."); - onClose(); - router.push(`/${frameworkInstance.organizationId}/frameworks`); - }, - onError: () => { - toast.error("Failed to delete framework."); - setIsSubmitting(false); - }, - }); + const deleteFramework = useAction(deleteFrameworkAction, { + onSuccess: () => { + toast.info("Framework deleted! Redirecting to frameworks list..."); + onClose(); + router.push(`/${frameworkInstance.organizationId}/frameworks`); + }, + onError: () => { + toast.error("Failed to delete framework."); + setIsSubmitting(false); + }, + }); - const handleSubmit = async (values: FormValues) => { - setIsSubmitting(true); - deleteFramework.execute({ - id: frameworkInstance.id, - entityId: frameworkInstance.id, - }); - }; + const handleSubmit = async (values: FormValues) => { + setIsSubmitting(true); + deleteFramework.execute({ + id: frameworkInstance.id, + entityId: frameworkInstance.id, + }); + }; - return ( - !open && onClose()}> - - - Delete Framework - - Are you sure you want to delete this framework? This - action cannot be undone. - - -
- - - - - -
- -
-
- ); + return ( + !open && onClose()}> + + + Delete Framework + + Are you sure you want to delete this framework? This action cannot + be undone. + + +
+ + + + + +
+ +
+
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkOverview.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkOverview.tsx index 2cf8feda6e..02d28a2a3d 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkOverview.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkOverview.tsx @@ -2,21 +2,23 @@ import { Badge } from "@comp/ui/badge"; import { Button } from "@comp/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@comp/ui/card"; import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "@comp/ui/card"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, } from "@comp/ui/dropdown-menu"; import { Progress } from "@comp/ui/progress"; import { Control, Task } from "@comp/db/types"; -import { MoreVertical, Trash2, CheckCircle2, Clock, BarChart3, Target } from "lucide-react"; +import { + MoreVertical, + Trash2, + CheckCircle2, + Clock, + BarChart3, + Target, +} from "lucide-react"; import { useState } from "react"; import { cn } from "@comp/ui/cn"; import { getControlStatus } from "../../lib/utils"; @@ -24,153 +26,165 @@ import { FrameworkInstanceWithControls } from "../../types"; import { FrameworkDeleteDialog } from "./FrameworkDeleteDialog"; interface FrameworkOverviewProps { - frameworkInstanceWithControls: FrameworkInstanceWithControls; - tasks: (Task & { controls: Control[] })[]; + frameworkInstanceWithControls: FrameworkInstanceWithControls; + tasks: (Task & { controls: Control[] })[]; } export function FrameworkOverview({ - frameworkInstanceWithControls, - tasks, + frameworkInstanceWithControls, + tasks, }: FrameworkOverviewProps) { - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [dropdownOpen, setDropdownOpen] = useState(false); - - // Get all controls from all requirements - const allControls = frameworkInstanceWithControls.controls; - const totalControls = allControls.length; + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [dropdownOpen, setDropdownOpen] = useState(false); + + // Get all controls from all requirements + const allControls = frameworkInstanceWithControls.controls; + const totalControls = allControls.length; - // Calculate compliant controls (all artifacts completed) - const compliantControls = allControls.filter( - (control: any) => - getControlStatus(control.policies, tasks, control.id) === - "completed", - ).length; + // Calculate compliant controls (all artifacts completed) + const compliantControls = allControls.filter( + (control: any) => + getControlStatus(control.policies, tasks, control.id) === "completed", + ).length; - // Calculate compliance percentage based on compliant controls - const compliancePercentage = - totalControls > 0 - ? Math.round((compliantControls / totalControls) * 100) - : 0; + // Calculate compliance percentage based on compliant controls + const compliancePercentage = + totalControls > 0 + ? Math.round((compliantControls / totalControls) * 100) + : 0; - const getComplianceColor = (score: number) => { - if (score >= 80) return "text-green-600 dark:text-green-400"; - if (score >= 60) return "text-yellow-600 dark:text-yellow-400"; - return "text-red-600 dark:text-red-400"; - }; + const getComplianceColor = (score: number) => { + if (score >= 80) return "text-green-600 dark:text-green-400"; + if (score >= 60) return "text-yellow-600 dark:text-yellow-400"; + return "text-red-600 dark:text-red-400"; + }; - const getComplianceBadgeVariant = () => { - if (compliancePercentage >= 80) return "default"; - if (compliancePercentage >= 60) return "secondary"; - return "destructive"; - }; + const getComplianceBadgeVariant = () => { + if (compliancePercentage >= 80) return "default"; + if (compliancePercentage >= 60) return "secondary"; + return "destructive"; + }; - const inProgressControls = totalControls - compliantControls; + const inProgressControls = totalControls - compliantControls; - return ( -
- {/* Framework Header */} -
-
-
-

- {frameworkInstanceWithControls.framework.name} -

- - {compliancePercentage}% - -
-

- {frameworkInstanceWithControls.framework.description} -

-
- - - - - - { - setDropdownOpen(false); - setDeleteDialogOpen(true); - }} - className="text-destructive focus:text-destructive" - > - - Delete Framework - - - -
+ return ( +
+ {/* Framework Header */} +
+
+
+

+ {frameworkInstanceWithControls.framework.name} +

+ + {compliancePercentage}% + +
+

+ {frameworkInstanceWithControls.framework.description} +

+
+ + + + + + { + setDropdownOpen(false); + setDeleteDialogOpen(true); + }} + className="text-destructive focus:text-destructive" + > + + Delete Framework + + + +
- {/* Compliance Dashboard */} -
- {/* Progress Card */} - - - - - Compliance Progress - - - -
-
-
- - {compliancePercentage} - - % complete -
- -
-
-
- {compliantControls} completed - {inProgressControls} remaining - {totalControls} total -
-
-
+ {/* Compliance Dashboard */} +
+ {/* Progress Card */} + + + + + Compliance Progress + + + +
+
+
+ + {compliancePercentage} + + + % complete + +
+ +
+
+
+ {compliantControls} completed + {inProgressControls} remaining + {totalControls} total +
+
+
- {/* Stats Card */} - - - - - Control Status - - - -
-
-
- Complete -
- {compliantControls} -
-
-
-
- In Progress -
- {inProgressControls} -
-
- Total - {totalControls} -
-
-
-
+ {/* Stats Card */} + + + + + Control Status + + + +
+
+
+ Complete +
+ + {compliantControls} + +
+
+
+
+ In Progress +
+ + {inProgressControls} + +
+
+ Total + + {totalControls} + +
+
+
+
- {/* Delete Dialog */} - setDeleteDialogOpen(false)} - frameworkInstance={frameworkInstanceWithControls} - /> -
- ); + {/* Delete Dialog */} + setDeleteDialogOpen(false)} + frameworkInstance={frameworkInstanceWithControls} + /> +
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkRequirements.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkRequirements.tsx index 9cd30a0e3f..f5c29c8d2b 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkRequirements.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkRequirements.tsx @@ -12,130 +12,125 @@ import { useMemo } from "react"; import type { FrameworkInstanceWithControls } from "../../types"; interface RequirementItem extends FrameworkEditorRequirement { - mappedControlsCount: number; + mappedControlsCount: number; } export function FrameworkRequirements({ - requirementDefinitions, - frameworkInstanceWithControls, + requirementDefinitions, + frameworkInstanceWithControls, }: { - requirementDefinitions: FrameworkEditorRequirement[]; - frameworkInstanceWithControls: FrameworkInstanceWithControls; + requirementDefinitions: FrameworkEditorRequirement[]; + frameworkInstanceWithControls: FrameworkInstanceWithControls; }) { - const { orgId, frameworkInstanceId } = useParams<{ - orgId: string; - frameworkInstanceId: string; - }>(); + const { orgId, frameworkInstanceId } = useParams<{ + orgId: string; + frameworkInstanceId: string; + }>(); - const items = useMemo(() => { - return requirementDefinitions.map((def) => { - const mappedControlsCount = - frameworkInstanceWithControls.controls.filter( - (control) => - control.requirementsMapped?.some( - (reqMap) => reqMap.requirementId === def.id, - ) ?? false, - ).length; + const items = useMemo(() => { + return requirementDefinitions.map((def) => { + const mappedControlsCount = frameworkInstanceWithControls.controls.filter( + (control) => + control.requirementsMapped?.some( + (reqMap) => reqMap.requirementId === def.id, + ) ?? false, + ).length; - return { - ...def, - mappedControlsCount, - }; - }); - }, [requirementDefinitions, frameworkInstanceWithControls.controls]); + return { + ...def, + mappedControlsCount, + }; + }); + }, [requirementDefinitions, frameworkInstanceWithControls.controls]); - const columns = useMemo[]>( - () => [ - { - accessorKey: "name", - header: ({ column }) => ( - - ), - cell: ({ row }) => ( - - {row.original.name} - - ), - enableSorting: true, - size: 200, - minSize: 150, - maxSize: 250, - meta: { - label: "Requirement Name", - placeholder: "Search...", - variant: "text", - }, - enableColumnFilter: true, - }, - { - accessorKey: "description", - header: ({ column }) => ( - - ), - cell: ({ row }) => ( - - {row.original.description} - - ), - enableSorting: true, - size: 500, - minSize: 300, - maxSize: 700, - enableResizing: true, - }, - { - accessorKey: "mappedControlsCount", - header: ({ column }) => ( - - ), - cell: ({ row }) => ( - - {row.original.mappedControlsCount} - - ), - size: 25, - minSize: 25, - maxSize: 25, - enableSorting: true, - enableResizing: true, - }, - ], - [], - ); + const columns = useMemo[]>( + () => [ + { + accessorKey: "name", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.name} + + ), + enableSorting: true, + size: 200, + minSize: 150, + maxSize: 250, + meta: { + label: "Requirement Name", + placeholder: "Search...", + variant: "text", + }, + enableColumnFilter: true, + }, + { + accessorKey: "description", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.description} + + ), + enableSorting: true, + size: 500, + minSize: 300, + maxSize: 700, + enableResizing: true, + }, + { + accessorKey: "mappedControlsCount", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.mappedControlsCount} + + ), + size: 25, + minSize: 25, + maxSize: 25, + enableSorting: true, + enableResizing: true, + }, + ], + [], + ); - const table = useDataTable({ - data: items, - columns, - pageCount: 1, - shallow: false, - getRowId: (row) => row.id, - initialState: { - sorting: [{ id: "name", desc: false }], - }, - }); + const table = useDataTable({ + data: items, + columns, + pageCount: 1, + shallow: false, + getRowId: (row) => row.id, + initialState: { + sorting: [{ id: "name", desc: false }], + }, + }); - if (!items?.length) { - return null; - } + if (!items?.length) { + return null; + } - return ( -
-

Requirements ({table.table.getFilteredRowModel().rows.length})

- row.id} - > - - {/* */} - - -
- ); + return ( +
+

+ Requirements ({table.table.getFilteredRowModel().rows.length}) +

+ row.id} + > + + {/* */} + + +
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/table/FrameworkControlsTable.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/table/FrameworkControlsTable.tsx index 96ab70954f..c8e068036a 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/table/FrameworkControlsTable.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/table/FrameworkControlsTable.tsx @@ -3,71 +3,66 @@ import { Loading } from "@/components/tables/risk-tasks/loading"; import { Table, TableBody, TableCell, TableRow } from "@comp/ui/table"; import { - flexRender, - getCoreRowModel, - useReactTable, + flexRender, + getCoreRowModel, + useReactTable, } from "@tanstack/react-table"; import { Suspense } from "react"; import { - FrameworkControlsTableColumns, - type OrganizationControlType, + FrameworkControlsTableColumns, + type OrganizationControlType, } from "./FrameworkControlsTableColumns"; import { FrameworkControlsTableHeader } from "./FrameworkControlsTableHeader"; interface DataTableProps { - data: OrganizationControlType[]; + data: OrganizationControlType[]; } export function FrameworkControlsTable({ data }: DataTableProps) { - const columns = FrameworkControlsTableColumns(); + const columns = FrameworkControlsTableColumns(); - const table = useReactTable({ - data: data, - columns, - getCoreRowModel: getCoreRowModel(), - }); + const table = useReactTable({ + data: data, + columns, + getCoreRowModel: getCoreRowModel(), + }); - return ( - }> -
- - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} - - ))} - - )) - ) : ( - - - No controls found. - - - )} - -
-
-
- ); + return ( + }> +
+ + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + No controls found. + + + )} + +
+
+
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/table/FrameworkControlsTableColumns.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/table/FrameworkControlsTableColumns.tsx index 266498a16f..0ec74906b0 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/table/FrameworkControlsTableColumns.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/table/FrameworkControlsTableColumns.tsx @@ -3,116 +3,108 @@ import { StatusIndicator, StatusType } from "@/components/status-indicator"; import type { Policy, PolicyStatus } from "@comp/db/types"; import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, } from "@comp/ui/tooltip"; import type { ColumnDef } from "@tanstack/react-table"; import Link from "next/link"; import { useParams } from "next/navigation"; export type OrganizationControlType = { - id: string; - name: string; - description: string | null; - frameworkInstanceId: string; - policies: Policy[]; + id: string; + name: string; + description: string | null; + frameworkInstanceId: string; + policies: Policy[]; }; -export function getControlStatusForPolicies( - policies: Policy[], -): StatusType { - if (!policies || policies.length === 0) return "not_started"; +export function getControlStatusForPolicies(policies: Policy[]): StatusType { + if (!policies || policies.length === 0) return "not_started"; - const totalPolicies = policies.length; + const totalPolicies = policies.length; - const completedPolicies = policies.filter((policy) => { - return policy.status === "published"; - }).length; + const completedPolicies = policies.filter((policy) => { + return policy.status === "published"; + }).length; - if (completedPolicies === 0) return "not_started"; - if (completedPolicies === totalPolicies) return "completed"; - return "in_progress"; + if (completedPolicies === 0) return "not_started"; + if (completedPolicies === totalPolicies) return "completed"; + return "in_progress"; } function isPolicyCompleted(policy: Policy): boolean { - if (!policy) return false; - return policy.status === "published"; + if (!policy) return false; + return policy.status === "published"; } export function FrameworkControlsTableColumns(): ColumnDef[] { - const { orgId } = useParams<{ orgId: string }>(); + const { orgId } = useParams<{ orgId: string }>(); - return [ - { - id: "name", - accessorKey: "name", - header: "Control", - cell: ({ row }) => { - return ( -
- - - {row.original.name} - - -
- ); - }, - }, - { - id: "category", - accessorKey: "name", - header: "Category", - cell: ({ row }) => ( -
- {row.original.name} -
- ), - }, - { - id: "status", - accessorKey: "policies", - header: "Status", - cell: ({ row }) => { - const policies = row.original.policies || []; - const status = getControlStatusForPolicies(policies); + return [ + { + id: "name", + accessorKey: "name", + header: "Control", + cell: ({ row }) => { + return ( +
+ + {row.original.name} + +
+ ); + }, + }, + { + id: "category", + accessorKey: "name", + header: "Category", + cell: ({ row }) => ( +
+ {row.original.name} +
+ ), + }, + { + id: "status", + accessorKey: "policies", + header: "Status", + cell: ({ row }) => { + const policies = row.original.policies || []; + const status = getControlStatusForPolicies(policies); - const totalPolicies = policies.length; - const completedPolicies = policies.filter(isPolicyCompleted).length; + const totalPolicies = policies.length; + const completedPolicies = policies.filter(isPolicyCompleted).length; - return ( - - - -
- -
-
- -
-

- Progress:{" "} - {Math.round( - (completedPolicies / - totalPolicies) * - 100, - ) || 0} - % -

-

- Completed: {completedPolicies}/{totalPolicies} policies -

-
-
-
-
- ); - }, - }, - ]; + return ( + + + +
+ +
+
+ +
+

+ Progress:{" "} + {Math.round((completedPolicies / totalPolicies) * 100) || 0} + % +

+

+ Completed: {completedPolicies}/{totalPolicies} policies +

+
+
+
+
+ ); + }, + }, + ]; } diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/table/FrameworkControlsTableHeader.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/table/FrameworkControlsTableHeader.tsx index 596f1a10f1..0b58f6b07e 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/table/FrameworkControlsTableHeader.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/table/FrameworkControlsTableHeader.tsx @@ -7,110 +7,102 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useCallback } from "react"; type Props = { - table?: { - getIsAllPageRowsSelected: () => boolean; - getIsSomePageRowsSelected: () => boolean; - getAllLeafColumns: () => { - id: string; - getIsVisible: () => boolean; - }[]; - toggleAllPageRowsSelected: (value: boolean) => void; - }; - loading?: boolean; - isEmpty?: boolean; + table?: { + getIsAllPageRowsSelected: () => boolean; + getIsSomePageRowsSelected: () => boolean; + getAllLeafColumns: () => { + id: string; + getIsVisible: () => boolean; + }[]; + toggleAllPageRowsSelected: (value: boolean) => void; + }; + loading?: boolean; + isEmpty?: boolean; }; export function FrameworkControlsTableHeader({ table, loading }: Props) { - const searchParams = useSearchParams(); - const pathname = usePathname(); - const router = useRouter(); - const sortParam = searchParams.get("sort"); - const [column, value] = sortParam ? sortParam.split(":") : []; + const searchParams = useSearchParams(); + const pathname = usePathname(); + const router = useRouter(); + const sortParam = searchParams.get("sort"); + const [column, value] = sortParam ? sortParam.split(":") : []; - const createSortQuery = useCallback( - (name: string) => { - const params = new URLSearchParams(searchParams); - const prevSort = params.get("sort"); + const createSortQuery = useCallback( + (name: string) => { + const params = new URLSearchParams(searchParams); + const prevSort = params.get("sort"); - if (`${name}:asc` === prevSort) { - params.set("sort", `${name}:desc`); - } else if (`${name}:desc` === prevSort) { - params.delete("sort"); - } else { - params.set("sort", `${name}:asc`); - } + if (`${name}:asc` === prevSort) { + params.set("sort", `${name}:desc`); + } else if (`${name}:desc` === prevSort) { + params.delete("sort"); + } else { + params.set("sort", `${name}:asc`); + } - router.replace(`${pathname}?${params.toString()}`); - }, - [searchParams, router, pathname], - ); + router.replace(`${pathname}?${params.toString()}`); + }, + [searchParams, router, pathname], + ); - const isVisible = (id: string) => - loading || - table - ?.getAllLeafColumns() - .find((col) => col.id === id) - ?.getIsVisible(); + const isVisible = (id: string) => + loading || + table + ?.getAllLeafColumns() + .find((col) => col.id === id) + ?.getIsVisible(); - return ( - - - {isVisible("name") && ( - - - - )} + return ( + + + {isVisible("name") && ( + + + + )} - {isVisible("category") && ( - - - - )} + {isVisible("category") && ( + + + + )} - {isVisible("status") && ( - - - - )} - - - ); + {isVisible("status") && ( + + + + )} + + + ); } diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/loading.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/loading.tsx index df722fafee..95aef2f538 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/loading.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/loading.tsx @@ -1,125 +1,119 @@ import { Card, CardContent, CardHeader, CardTitle } from "@comp/ui/card"; import { Skeleton } from "@comp/ui/skeleton"; import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, } from "@comp/ui/table"; export default function Loading() { - return ( -
- {/* Framework Overview Skeleton */} -
- {/* Framework Info Card */} - - - - - - - - - - - - + return ( +
+ {/* Framework Overview Skeleton */} +
+ {/* Framework Info Card */} + + + + + + + + + + + + - {/* Compliance Progress Card */} - - - - - - - -
- - -
-
-
+ {/* Compliance Progress Card */} + + + + + + + +
+ + +
+
+
- {/* Assessment Status Card */} - - - - - - - -
-
- - -
-
- - -
-
-
-
-
+ {/* Assessment Status Card */} + + + + + + + +
+
+ + +
+
+ + +
+
+
+
+
- {/* Framework Controls Table Skeleton */} -
- - - - {["code", "name", "status"].map((column) => ( - - - - ))} - - - - {[ - "control1", - "control2", - "control3", - "control4", - "control5", - "control6", - "control7", - "control8", - "control9", - "control10", - "control11", - "control12", - "control13", - "control14", - "control15", - "control16", - "control17", - "control18", - ].map((control) => ( - - {["code", "name", "status"].map((column) => ( - - - - ))} - - ))} - -
-
-
- ); + {/* Framework Controls Table Skeleton */} +
+ + + + {["code", "name", "status"].map((column) => ( + + + + ))} + + + + {[ + "control1", + "control2", + "control3", + "control4", + "control5", + "control6", + "control7", + "control8", + "control9", + "control10", + "control11", + "control12", + "control13", + "control14", + "control15", + "control16", + "control17", + "control18", + ].map((control) => ( + + {["code", "name", "status"].map((column) => ( + + + + ))} + + ))} + +
+
+
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/page.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/page.tsx index 013de87d02..dfa436c914 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/page.tsx @@ -9,87 +9,81 @@ import { FrameworkRequirements } from "./components/FrameworkRequirements"; import { Separator } from "@comp/ui/separator"; interface PageProps { - params: Promise<{ - frameworkInstanceId: string; - }>; + params: Promise<{ + frameworkInstanceId: string; + }>; } export default async function FrameworkPage({ params }: PageProps) { - const { frameworkInstanceId } = await params; + const { frameworkInstanceId } = await params; - const session = await auth.api.getSession({ - headers: await headers(), - }); + const session = await auth.api.getSession({ + headers: await headers(), + }); - if (!session) { - redirect("/"); - } + if (!session) { + redirect("/"); + } - const organizationId = session.session.activeOrganizationId; + const organizationId = session.session.activeOrganizationId; - if (!organizationId) { - redirect("/"); - } + if (!organizationId) { + redirect("/"); + } - const frameworkInstanceWithControls = - await getSingleFrameworkInstanceWithControls({ - organizationId, - frameworkInstanceId, - }); + const frameworkInstanceWithControls = + await getSingleFrameworkInstanceWithControls({ + organizationId, + frameworkInstanceId, + }); - if (!frameworkInstanceWithControls) { - redirect("/"); - } + if (!frameworkInstanceWithControls) { + redirect("/"); + } - // Fetch requirement definitions for this framework - const requirementDefinitions = await db.frameworkEditorRequirement.findMany( - { - where: { - frameworkId: frameworkInstanceWithControls.frameworkId, - }, - orderBy: { - name: "asc", - }, - }, - ); + // Fetch requirement definitions for this framework + const requirementDefinitions = await db.frameworkEditorRequirement.findMany({ + where: { + frameworkId: frameworkInstanceWithControls.frameworkId, + }, + orderBy: { + name: "asc", + }, + }); - const frameworkName = frameworkInstanceWithControls.framework.name; + const frameworkName = frameworkInstanceWithControls.framework.name; - const tasks = await db.task.findMany({ - where: { - organizationId, - controls: { - some: { - id: frameworkInstanceWithControls.id, - }, - }, - }, - include: { - controls: true, - }, - }); + const tasks = await db.task.findMany({ + where: { + organizationId, + controls: { + some: { + id: frameworkInstanceWithControls.id, + }, + }, + }, + include: { + controls: true, + }, + }); - return ( - -
- - -
-
- ); + return ( + +
+ + +
+
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/requirements/[requirementKey]/components/RequirementControls.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/requirements/[requirementKey]/components/RequirementControls.tsx index bbd3a661bb..abf95464c3 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/requirements/[requirementKey]/components/RequirementControls.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/requirements/[requirementKey]/components/RequirementControls.tsx @@ -5,44 +5,44 @@ import { RequirementControlsTable } from "./table/RequirementControlsTable"; import type { Control, RequirementMap, Task } from "@comp/db/types"; interface RequirementControlsProps { - requirement: FrameworkEditorRequirement; - tasks: (Task & { controls: Control[] })[]; - relatedControls: (RequirementMap & { control: Control })[]; + requirement: FrameworkEditorRequirement; + tasks: (Task & { controls: Control[] })[]; + relatedControls: (RequirementMap & { control: Control })[]; } export function RequirementControls({ - requirement, - tasks, - relatedControls, + requirement, + tasks, + relatedControls, }: RequirementControlsProps) { - return ( -
- {/* Requirement Header */} -
-

{requirement.name}

- {requirement.description && ( -

- {requirement.description} -

- )} -
+ return ( +
+ {/* Requirement Header */} +
+

{requirement.name}

+ {requirement.description && ( +

+ {requirement.description} +

+ )} +
- {/* Controls Section */} -
-
-
-

Controls

- - {relatedControls.length} - -
-
- - control.control)} - tasks={tasks} - /> -
-
- ); + {/* Controls Section */} +
+
+
+

Controls

+ + {relatedControls.length} + +
+
+ + control.control)} + tasks={tasks} + /> +
+
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/requirements/[requirementKey]/components/table/RequirementControlsTable.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/requirements/[requirementKey]/components/table/RequirementControlsTable.tsx index 7476157fd9..b5062aa7f0 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/requirements/[requirementKey]/components/table/RequirementControlsTable.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/requirements/[requirementKey]/components/table/RequirementControlsTable.tsx @@ -11,92 +11,87 @@ import { useParams } from "next/navigation"; import { useMemo, useState } from "react"; interface RequirementControlsTableProps { - controls: (Control)[]; - tasks: (Task & { controls: Control[] })[]; + controls: Control[]; + tasks: (Task & { controls: Control[] })[]; } export function RequirementControlsTable({ - controls, - tasks, + controls, + tasks, }: RequirementControlsTableProps) { - const { orgId } = useParams<{ orgId: string }>(); - const [searchTerm, setSearchTerm] = useState(""); + const { orgId } = useParams<{ orgId: string }>(); + const [searchTerm, setSearchTerm] = useState(""); - // Define columns for the controls table - const columns = useMemo[]>( - () => [ - { - id: "name", - accessorKey: "name", - header: ({ column }) => ( - - ), - cell: ({ row }) => ( -
- - - {row.original.name} - - -
- ), - enableSorting: true, - size: 300, - minSize: 200, - maxSize: 400, - enableResizing: true, - }, - ], - [orgId], - ); + // Define columns for the controls table + const columns = useMemo[]>( + () => [ + { + id: "name", + accessorKey: "name", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ + {row.original.name} + +
+ ), + enableSorting: true, + size: 300, + minSize: 200, + maxSize: 400, + enableResizing: true, + }, + ], + [orgId], + ); - // Filter controls data based on search term - const filteredControls = useMemo(() => { - if (!controls?.length) return []; - if (!searchTerm.trim()) return controls; + // Filter controls data based on search term + const filteredControls = useMemo(() => { + if (!controls?.length) return []; + if (!searchTerm.trim()) return controls; - const searchLower = searchTerm.toLowerCase(); - return controls.filter((control) => - control.name.toLowerCase().includes(searchLower), - ); - }, [controls, searchTerm]); + const searchLower = searchTerm.toLowerCase(); + return controls.filter((control) => + control.name.toLowerCase().includes(searchLower), + ); + }, [controls, searchTerm]); - // Set up the controls table - const table = useDataTable({ - data: filteredControls, - columns, - pageCount: 1, - shallow: false, - getRowId: (row) => row.id, - initialState: { - sorting: [{ id: "name", desc: false }], - }, - }); + // Set up the controls table + const table = useDataTable({ + data: filteredControls, + columns, + pageCount: 1, + shallow: false, + getRowId: (row) => row.id, + initialState: { + sorting: [{ id: "name", desc: false }], + }, + }); - return ( -
-
- setSearchTerm(e.target.value)} - className="max-w-sm" - /> - {/*
+ return ( +
+
+ setSearchTerm(e.target.value)} + className="max-w-sm" + /> + {/*
*/} -
- row.id} - /> -
- ); +
+ row.id} + /> +
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/requirements/[requirementKey]/components/table/RequirementControlsTableColumns.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/requirements/[requirementKey]/components/table/RequirementControlsTableColumns.tsx index 4709bc99fd..7b1bce29a7 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/requirements/[requirementKey]/components/table/RequirementControlsTableColumns.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/requirements/[requirementKey]/components/table/RequirementControlsTableColumns.tsx @@ -4,10 +4,10 @@ import { isPolicyCompleted } from "@/lib/control-compliance"; import { StatusIndicator } from "@/components/status-indicator"; import type { Control, Policy } from "@comp/db/types"; import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, } from "@comp/ui/tooltip"; import type { ColumnDef } from "@tanstack/react-table"; import Link from "next/link"; @@ -16,78 +16,71 @@ import { getControlStatus } from "../../../../../lib/utils"; import type { Task } from "@comp/db/types"; export type OrganizationControlType = Control & { - policies: Policy[]; + policies: Policy[]; }; export function RequirementControlsTableColumns({ - tasks, + tasks, }: { - tasks: (Task & { controls: Control[] })[]; + tasks: (Task & { controls: Control[] })[]; }): ColumnDef[] { - const { orgId } = useParams<{ orgId: string }>(); + const { orgId } = useParams<{ orgId: string }>(); - return [ - { - id: "name", - accessorKey: "name", - header: "Control", - cell: ({ row }) => { - return ( -
- - - {row.original.name} - - -
- ); - }, - }, - { - id: "status", - accessorKey: "policies", - header: "Status", - cell: ({ row }) => { - const controlData = row.original; - const policies = controlData.policies || []; + return [ + { + id: "name", + accessorKey: "name", + header: "Control", + cell: ({ row }) => { + return ( +
+ + {row.original.name} + +
+ ); + }, + }, + { + id: "status", + accessorKey: "policies", + header: "Status", + cell: ({ row }) => { + const controlData = row.original; + const policies = controlData.policies || []; - const status = getControlStatus(policies, tasks, controlData.id); + const status = getControlStatus(policies, tasks, controlData.id); - const totalPolicies = policies.length; - const completedPolicies = policies.filter(isPolicyCompleted).length; + const totalPolicies = policies.length; + const completedPolicies = policies.filter(isPolicyCompleted).length; - return ( - - - -
- -
-
- -
-

- Progress:{" "} - {Math.round( - (completedPolicies / - totalPolicies) * - 100, - ) || 0} - % -

-

- Completed: {completedPolicies}/ - {totalPolicies} policies -

-
-
-
-
- ); - }, - }, - ]; + return ( + + + +
+ +
+
+ +
+

+ Progress:{" "} + {Math.round((completedPolicies / totalPolicies) * 100) || 0} + % +

+

+ Completed: {completedPolicies}/{totalPolicies} policies +

+
+
+
+
+ ); + }, + }, + ]; } diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/requirements/[requirementKey]/loading.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/requirements/[requirementKey]/loading.tsx index e592bdaf86..acafda3399 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/requirements/[requirementKey]/loading.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/requirements/[requirementKey]/loading.tsx @@ -1,64 +1,64 @@ import { Card, CardContent, CardHeader, CardTitle } from "@comp/ui/card"; import { Skeleton } from "@comp/ui/skeleton"; import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, } from "@comp/ui/table"; export default function Loading() { - return ( -
- - - -
- - -
-
-
- - - -
+ return ( +
+ + + +
+ + +
+
+
+ + + +
- - - - - - - - - - - - - - - - - - - - {[1, 2, 3].map((index) => ( - - - - - - - - - ))} - -
-
-
-
- ); + + + + + + + + + + + + + + + + + + + + {[1, 2, 3].map((index) => ( + + + + + + + + + ))} + +
+
+
+
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/requirements/[requirementKey]/page.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/requirements/[requirementKey]/page.tsx index e6f236cd8c..35caa95808 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/requirements/[requirementKey]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/requirements/[requirementKey]/page.tsx @@ -8,115 +8,118 @@ import { db } from "@comp/db"; import type { FrameworkEditorRequirement } from "@comp/db/types"; interface PageProps { - params: Promise<{ - frameworkInstanceId: string; - requirementKey: string; - }>; + params: Promise<{ + frameworkInstanceId: string; + requirementKey: string; + }>; } export default async function RequirementPage({ params }: PageProps) { - const { frameworkInstanceId, requirementKey } = await params; - - const session = await auth.api.getSession({ - headers: await headers(), - }); - - if (!session) { - redirect("/"); - } - - const organizationId = session.session.activeOrganizationId; - - if (!organizationId) { - redirect("/"); - } - - const frameworkInstanceWithControls = - await getSingleFrameworkInstanceWithControls({ - organizationId, - frameworkInstanceId, - }); - - if (!frameworkInstanceWithControls) { - redirect("/"); - } - - const allReqDefsForFramework = await db.frameworkEditorRequirement.findMany({ - where: { - frameworkId: frameworkInstanceWithControls.frameworkId, - }, - }); - - const requirementsFromDb = allReqDefsForFramework.reduce< - Record - >((acc, def) => { - acc[def.id] = def; - return acc; - }, {}); - - const currentRequirementDetails = requirementsFromDb[requirementKey]; - - if (!currentRequirementDetails) { - redirect(`/${organizationId}/frameworks/${frameworkInstanceId}`); - } - - const frameworkName = frameworkInstanceWithControls.framework.name; - - const siblingRequirements = allReqDefsForFramework.filter( - (def) => def.id !== requirementKey, - ); - - const siblingRequirementsDropdown = siblingRequirements.map((def) => ({ - label: def.name, - href: `/${organizationId}/frameworks/${frameworkInstanceId}/requirements/${def.id}`, - })); - - const tasks = - (await db.task.findMany({ - where: { - organizationId, - }, - include: { - controls: true, - }, - })) || []; - - const relatedControls = await db.requirementMap.findMany({ - where: { - frameworkInstanceId, - requirementId: requirementKey, - }, - include: { - control: true, - }, - }); - - console.log("relatedControls", relatedControls); - - const maxLabelLength = 40; - - return ( - maxLabelLength ? `${currentRequirementDetails.name.slice(0, maxLabelLength)}...` : currentRequirementDetails.name, - dropdown: siblingRequirementsDropdown, - current: true, - }, - ]} - > -
- -
-
- ); + const { frameworkInstanceId, requirementKey } = await params; + + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session) { + redirect("/"); + } + + const organizationId = session.session.activeOrganizationId; + + if (!organizationId) { + redirect("/"); + } + + const frameworkInstanceWithControls = + await getSingleFrameworkInstanceWithControls({ + organizationId, + frameworkInstanceId, + }); + + if (!frameworkInstanceWithControls) { + redirect("/"); + } + + const allReqDefsForFramework = await db.frameworkEditorRequirement.findMany({ + where: { + frameworkId: frameworkInstanceWithControls.frameworkId, + }, + }); + + const requirementsFromDb = allReqDefsForFramework.reduce< + Record + >((acc, def) => { + acc[def.id] = def; + return acc; + }, {}); + + const currentRequirementDetails = requirementsFromDb[requirementKey]; + + if (!currentRequirementDetails) { + redirect(`/${organizationId}/frameworks/${frameworkInstanceId}`); + } + + const frameworkName = frameworkInstanceWithControls.framework.name; + + const siblingRequirements = allReqDefsForFramework.filter( + (def) => def.id !== requirementKey, + ); + + const siblingRequirementsDropdown = siblingRequirements.map((def) => ({ + label: def.name, + href: `/${organizationId}/frameworks/${frameworkInstanceId}/requirements/${def.id}`, + })); + + const tasks = + (await db.task.findMany({ + where: { + organizationId, + }, + include: { + controls: true, + }, + })) || []; + + const relatedControls = await db.requirementMap.findMany({ + where: { + frameworkInstanceId, + requirementId: requirementKey, + }, + include: { + control: true, + }, + }); + + console.log("relatedControls", relatedControls); + + const maxLabelLength = 40; + + return ( + maxLabelLength + ? `${currentRequirementDetails.name.slice(0, maxLabelLength)}...` + : currentRequirementDetails.name, + dropdown: siblingRequirementsDropdown, + current: true, + }, + ]} + > +
+ +
+
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/components/AddFrameworkModal.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/components/AddFrameworkModal.tsx index 10dc8a6278..6e5fae7bba 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/components/AddFrameworkModal.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/components/AddFrameworkModal.tsx @@ -12,209 +12,209 @@ import { Button } from "@comp/ui/button"; import { Checkbox } from "@comp/ui/checkbox"; import { cn } from "@comp/ui/cn"; import { - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, } from "@comp/ui/dialog"; import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, } from "@comp/ui/form"; import type { FrameworkEditorFramework } from "@comp/db/types"; import { addFrameworksToOrganizationAction } from "@/actions/organization/add-frameworks-to-organization-action"; // Will create this action next import { addFrameworksSchema } from "@/actions/schema"; // Will create/update this schema type Props = { - onOpenChange: (isOpen: boolean) => void; - availableFrameworks: Pick< - FrameworkEditorFramework, - "id" | "name" | "description" | "version" | "visible" - >[]; - organizationId: string; + onOpenChange: (isOpen: boolean) => void; + availableFrameworks: Pick< + FrameworkEditorFramework, + "id" | "name" | "description" | "version" | "visible" + >[]; + organizationId: string; }; export function AddFrameworkModal({ - onOpenChange, - availableFrameworks, - organizationId, + onOpenChange, + availableFrameworks, + organizationId, }: Props) { - const router = useRouter(); - const [isExecuting, setIsExecuting] = useState(false); + const router = useRouter(); + const [isExecuting, setIsExecuting] = useState(false); - const form = useForm>({ - resolver: zodResolver(addFrameworksSchema), - defaultValues: { - frameworkIds: [], - organizationId: organizationId, - }, - mode: "onChange", - }); + const form = useForm>({ + resolver: zodResolver(addFrameworksSchema), + defaultValues: { + frameworkIds: [], + organizationId: organizationId, + }, + mode: "onChange", + }); - const onSubmit = async (data: z.infer) => { - setIsExecuting(true); - try { - const result = await addFrameworksToOrganizationAction(data); - if (result.success) { - toast.success("Success"); // Assuming a generic success message - onOpenChange(false); - router.refresh(); // Refresh page to show new frameworks - } else { - toast.error(result.error || "Error"); - } - } catch (error) { - toast.error("Error"); - } finally { - setIsExecuting(false); - } - }; + const onSubmit = async (data: z.infer) => { + setIsExecuting(true); + try { + const result = await addFrameworksToOrganizationAction(data); + if (result.success) { + toast.success("Success"); // Assuming a generic success message + onOpenChange(false); + router.refresh(); // Refresh page to show new frameworks + } else { + toast.error(result.error || "Error"); + } + } catch (error) { + toast.error("Error"); + } finally { + setIsExecuting(false); + } + }; - const handleOpenChange = (open: boolean) => { - if (isExecuting && !open) return; - onOpenChange(open); - }; + const handleOpenChange = (open: boolean) => { + if (isExecuting && !open) return; + onOpenChange(open); + }; - return ( - - - {"Add New Frameworks"} - - {availableFrameworks.length > 0 - ? "Select the compliance frameworks you want to add to your organization." - : "There are no new frameworks available to add at this time."} - - + return ( + + + {"Add New Frameworks"} + + {availableFrameworks.length > 0 + ? "Select the compliance frameworks you want to add to your organization." + : "There are no new frameworks available to add at this time."} + + - {!isExecuting && availableFrameworks.length > 0 && ( -
- - ( - - - {"Select Frameworks"} - - -
-
- {availableFrameworks - .filter((framework) => framework.visible) - .map((framework) => { - const frameworkId = framework.id; - return ( - - ); - })} -
-
-
- -
- )} - /> - -
- - -
-
- - - )} + {!isExecuting && availableFrameworks.length > 0 && ( +
+ + ( + + + {"Select Frameworks"} + + +
+
+ {availableFrameworks + .filter((framework) => framework.visible) + .map((framework) => { + const frameworkId = framework.id; + return ( + + ); + })} +
+
+
+ +
+ )} + /> + +
+ + +
+
+ + + )} - {!isExecuting && availableFrameworks.length === 0 && ( -
-

- {"All available frameworks are already enabled in your account."} -

- - - -
- )} + {!isExecuting && availableFrameworks.length === 0 && ( +
+

+ {"All available frameworks are already enabled in your account."} +

+ + + +
+ )} - {isExecuting && ( -
- -

{"Adding frameworks..."}

-
- )} -
- ); + {isExecuting && ( +
+ +

{"Adding frameworks..."}

+
+ )} +
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworkCard.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworkCard.tsx index 988004a5bb..af12d1d5a0 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworkCard.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworkCard.tsx @@ -2,12 +2,7 @@ import type { Task, Control } from "@comp/db/types"; import { Badge } from "@comp/ui/badge"; -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "@comp/ui/card"; +import { Card, CardContent, CardHeader, CardTitle } from "@comp/ui/card"; import { Progress } from "@comp/ui/progress"; import { cn } from "@comp/ui/cn"; import { BarChart3, Clock } from "lucide-react"; @@ -16,145 +11,150 @@ import { useParams } from "next/navigation"; import type { FrameworkInstanceWithControls } from "../types"; interface FrameworkCardProps { - frameworkInstance: FrameworkInstanceWithControls; - complianceScore?: number; - tasks: (Task & { controls: Control[] })[]; + frameworkInstance: FrameworkInstanceWithControls; + complianceScore?: number; + tasks: (Task & { controls: Control[] })[]; } export function FrameworkCard({ - frameworkInstance, - complianceScore = 0, - tasks, + frameworkInstance, + complianceScore = 0, + tasks, }: FrameworkCardProps) { - const { orgId } = useParams<{ orgId: string }>(); - - const getStatusBadge = (score: number) => { - if (score >= 95) - return { - label: "Compliant", - variant: "default" as const, - }; - if (score >= 80) - return { - label: "Nearly Compliant", - variant: "secondary" as const, - }; - if (score >= 50) - return { - label: "In Progress", - variant: "outline" as const, - }; - return { - label: "Needs Attention", - variant: "destructive" as const, - }; - }; - - const getComplianceColor = (score: number) => { - if (score >= 80) return "text-green-600 dark:text-green-400"; - if (score >= 60) return "text-yellow-600 dark:text-yellow-400"; - return "text-red-600 dark:text-red-400"; - }; - - const controlsCount = frameworkInstance.controls?.length || 0; - const compliantControlsCount = Math.round( - (complianceScore / 100) * controlsCount, - ); - - // Calculate not started controls: controls where all policies are draft or non-existent AND all tasks are todo or non-existent - const notStartedControlsCount = - frameworkInstance.controls?.filter((control) => { - // If a control has no policies and no tasks, it's not started. - const controlTasks = tasks.filter((task) => - task.controls.some((c) => c.id === control.id), - ); - - if ( - (!control.policies || control.policies.length === 0) && - controlTasks.length === 0 - ) { - return true; - } - - // Check if ALL policies are in draft state or non-existent - const policiesNotStarted = - !control.policies || - control.policies.length === 0 || - control.policies.every((policy) => policy.status === "draft"); - - // Check if ALL tasks are in todo state or there are no tasks - const tasksNotStarted = - controlTasks.length === 0 || - controlTasks.every((task) => task.status === "todo"); - - return policiesNotStarted && tasksNotStarted; - // If either any policy is not draft or any task is not todo, it's in progress - }).length || 0; - - // Calculate in progress controls: Total - Compliant - Not Started - const inProgressCount = Math.max( - 0, // Ensure count doesn't go below zero - controlsCount - compliantControlsCount - notStartedControlsCount, - ); - - // Use direct framework data: - const frameworkDetails = frameworkInstance.framework; - const statusBadge = getStatusBadge(complianceScore); - - // Calculate last activity date - use current date as fallback - const lastActivityDate = new Date().toLocaleDateString(); - - return ( - - - -
-
- - {frameworkDetails.name} - -

- {frameworkDetails.description} -

-
- - {complianceScore}% - -
-
- - - {/* Progress Section */} -
-
-
- - Progress -
- - {complianceScore}% - -
- -
- - {/* Stats */} -
- {compliantControlsCount} complete - {inProgressCount} active - {controlsCount} total -
- - {/* Footer */} -
- - Updated {lastActivityDate} -
-
-
- - ); + const { orgId } = useParams<{ orgId: string }>(); + + const getStatusBadge = (score: number) => { + if (score >= 95) + return { + label: "Compliant", + variant: "default" as const, + }; + if (score >= 80) + return { + label: "Nearly Compliant", + variant: "secondary" as const, + }; + if (score >= 50) + return { + label: "In Progress", + variant: "outline" as const, + }; + return { + label: "Needs Attention", + variant: "destructive" as const, + }; + }; + + const getComplianceColor = (score: number) => { + if (score >= 80) return "text-green-600 dark:text-green-400"; + if (score >= 60) return "text-yellow-600 dark:text-yellow-400"; + return "text-red-600 dark:text-red-400"; + }; + + const controlsCount = frameworkInstance.controls?.length || 0; + const compliantControlsCount = Math.round( + (complianceScore / 100) * controlsCount, + ); + + // Calculate not started controls: controls where all policies are draft or non-existent AND all tasks are todo or non-existent + const notStartedControlsCount = + frameworkInstance.controls?.filter((control) => { + // If a control has no policies and no tasks, it's not started. + const controlTasks = tasks.filter((task) => + task.controls.some((c) => c.id === control.id), + ); + + if ( + (!control.policies || control.policies.length === 0) && + controlTasks.length === 0 + ) { + return true; + } + + // Check if ALL policies are in draft state or non-existent + const policiesNotStarted = + !control.policies || + control.policies.length === 0 || + control.policies.every((policy) => policy.status === "draft"); + + // Check if ALL tasks are in todo state or there are no tasks + const tasksNotStarted = + controlTasks.length === 0 || + controlTasks.every((task) => task.status === "todo"); + + return policiesNotStarted && tasksNotStarted; + // If either any policy is not draft or any task is not todo, it's in progress + }).length || 0; + + // Calculate in progress controls: Total - Compliant - Not Started + const inProgressCount = Math.max( + 0, // Ensure count doesn't go below zero + controlsCount - compliantControlsCount - notStartedControlsCount, + ); + + // Use direct framework data: + const frameworkDetails = frameworkInstance.framework; + const statusBadge = getStatusBadge(complianceScore); + + // Calculate last activity date - use current date as fallback + const lastActivityDate = new Date().toLocaleDateString(); + + return ( + + + +
+
+ + {frameworkDetails.name} + +

+ {frameworkDetails.description} +

+
+ + {complianceScore}% + +
+
+ + + {/* Progress Section */} +
+
+
+ + Progress +
+ + {complianceScore}% + +
+ +
+ + {/* Stats */} +
+ {compliantControlsCount} complete + {inProgressCount} active + {controlsCount} total +
+ + {/* Footer */} +
+ + Updated {lastActivityDate} +
+
+
+ + ); } diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworkList.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworkList.tsx index 0b86ca87c8..caaf7c2d18 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworkList.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworkList.tsx @@ -5,25 +5,24 @@ import { FrameworkCard } from "./FrameworkCard"; import type { FrameworkInstanceWithControls } from "../types"; export function FrameworkList({ - frameworksWithControls, - tasks, + frameworksWithControls, + tasks, }: { - frameworksWithControls: FrameworkInstanceWithControls[]; - tasks: (Task & { controls: Control[] })[]; + frameworksWithControls: FrameworkInstanceWithControls[]; + tasks: (Task & { controls: Control[] })[]; }) { - if (!frameworksWithControls.length) return null; + if (!frameworksWithControls.length) return null; - return ( -
- {frameworksWithControls.map((frameworkInstance) => ( - - ), - )} -
- ); + return ( +
+ {frameworksWithControls.map((frameworkInstance) => ( + + ))} +
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworksOverview.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworksOverview.tsx index ebe808ed0a..32191764f6 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworksOverview.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworksOverview.tsx @@ -1,4 +1,4 @@ -'use client'; +"use client"; import { Control, Task } from "@comp/db/types"; import { getFrameworkWithComplianceScores } from "../data/getFrameworkWithComplianceScores"; @@ -8,51 +8,60 @@ import type { FrameworkEditorFramework } from "@comp/db/types"; import { Button } from "@comp/ui/button"; import { useState } from "react"; import { AddFrameworkModal } from "./AddFrameworkModal"; -import { useParams } from 'next/navigation'; +import { useParams } from "next/navigation"; import { Dialog } from "@comp/ui/dialog"; import { PlusIcon } from "lucide-react"; export interface FrameworksOverviewProps { - frameworksWithControls: FrameworkInstanceWithControls[]; - tasks: (Task & { controls: Control[] })[]; - allFrameworks: FrameworkEditorFramework[]; + frameworksWithControls: FrameworkInstanceWithControls[]; + tasks: (Task & { controls: Control[] })[]; + allFrameworks: FrameworkEditorFramework[]; } export function FrameworksOverview({ - frameworksWithControls, - tasks, - allFrameworks, + frameworksWithControls, + tasks, + allFrameworks, }: FrameworksOverviewProps) { - const params = useParams<{ orgId: string }>(); - const organizationId = params.orgId; - const [isAddFrameworkModalOpen, setIsAddFrameworkModalOpen] = useState(false); + const params = useParams<{ orgId: string }>(); + const organizationId = params.orgId; + const [isAddFrameworkModalOpen, setIsAddFrameworkModalOpen] = useState(false); - const instancedFrameworkIds = frameworksWithControls.map(fw => fw.frameworkId); - const availableFrameworksToAdd = allFrameworks.filter(fw => !instancedFrameworkIds.includes(fw.id) && fw.visible); + const instancedFrameworkIds = frameworksWithControls.map( + (fw) => fw.frameworkId, + ); + const availableFrameworksToAdd = allFrameworks.filter( + (fw) => !instancedFrameworkIds.includes(fw.id) && fw.visible, + ); - return ( -
- -
- -
- -
-
- - {isAddFrameworkModalOpen && ( - - )} - -
- ); + return ( +
+
+ +
+ +
+
+ + {isAddFrameworkModalOpen && ( + + )} + +
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/components/types.ts b/apps/app/src/app/(app)/[orgId]/frameworks/components/types.ts index 0a321dfbee..38cf1f8d85 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/components/types.ts +++ b/apps/app/src/app/(app)/[orgId]/frameworks/components/types.ts @@ -1,32 +1,32 @@ import type { - FrameworkInstance, - IntegrationResult, - Policy, - Task, + FrameworkInstance, + IntegrationResult, + Policy, + Task, } from "@comp/db/types"; import { FrameworkInstanceWithControls } from "../types"; export interface ComplianceScoresProps { - policiesCompliance: number; - tasksCompliance: number; - cloudTestsCompliance: number; - overallCompliance: number; - frameworkCompliance: { - id: string; - name: string; - compliance: number; - }[]; - policies: Policy[]; - tasks: Task[]; - tests: IntegrationResult[]; + policiesCompliance: number; + tasksCompliance: number; + cloudTestsCompliance: number; + overallCompliance: number; + frameworkCompliance: { + id: string; + name: string; + compliance: number; + }[]; + policies: Policy[]; + tasks: Task[]; + tests: IntegrationResult[]; } export interface FrameworkWithCompliance { - framework: FrameworkInstance; - compliance: number; + framework: FrameworkInstance; + compliance: number; } export interface FrameworkInstanceWithComplianceScore { - frameworkInstance: FrameworkInstanceWithControls; - complianceScore: number; + frameworkInstance: FrameworkInstanceWithControls; + complianceScore: number; } diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/data/getAllFrameworkInstancesWithControls.ts b/apps/app/src/app/(app)/[orgId]/frameworks/data/getAllFrameworkInstancesWithControls.ts index 68a89405a1..1b299e87da 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/data/getAllFrameworkInstancesWithControls.ts +++ b/apps/app/src/app/(app)/[orgId]/frameworks/data/getAllFrameworkInstancesWithControls.ts @@ -6,73 +6,71 @@ import type { Control, PolicyStatus, RequirementMap } from "@comp/db/types"; import { cache } from "react"; export const getAllFrameworkInstancesWithControls = cache( - async function getAllFrameworkInstancesWithControls({ - organizationId, - }: { - organizationId: string; - }): Promise { - const frameworkInstancesFromDb = await db.frameworkInstance.findMany({ - where: { - organizationId, - }, - include: { - framework: true, - requirementsMapped: { - include: { - control: { - include: { - policies: { - select: { - id: true, - name: true, - status: true, - }, - }, - requirementsMapped: true, - }, - }, - }, - }, - }, - }); + async function getAllFrameworkInstancesWithControls({ + organizationId, + }: { + organizationId: string; + }): Promise { + const frameworkInstancesFromDb = await db.frameworkInstance.findMany({ + where: { + organizationId, + }, + include: { + framework: true, + requirementsMapped: { + include: { + control: { + include: { + policies: { + select: { + id: true, + name: true, + status: true, + }, + }, + requirementsMapped: true, + }, + }, + }, + }, + }, + }); - const frameworksWithControls: FrameworkInstanceWithControls[] = - frameworkInstancesFromDb.map((fi) => { - const controlsMap = new Map< - string, - Control & { - policies: Array<{ - id: string; - name: string; - status: PolicyStatus; - }>; - requirementsMapped: RequirementMap[]; - } - >(); + const frameworksWithControls: FrameworkInstanceWithControls[] = + frameworkInstancesFromDb.map((fi) => { + const controlsMap = new Map< + string, + Control & { + policies: Array<{ + id: string; + name: string; + status: PolicyStatus; + }>; + requirementsMapped: RequirementMap[]; + } + >(); - for (const rm of fi.requirementsMapped) { - if (rm.control) { - const { requirementsMapped: _, ...controlData } = - rm.control; - if (!controlsMap.has(rm.control.id)) { - controlsMap.set(rm.control.id, { - ...controlData, - policies: rm.control.policies || [], - requirementsMapped: - rm.control.requirementsMapped || [], - }); - } - } - } + for (const rm of fi.requirementsMapped) { + if (rm.control) { + const { requirementsMapped: _, ...controlData } = rm.control; + if (!controlsMap.has(rm.control.id)) { + controlsMap.set(rm.control.id, { + ...controlData, + policies: rm.control.policies || [], + requirementsMapped: rm.control.requirementsMapped || [], + }); + } + } + } - const { requirementsMapped, ...restOfFi } = fi; + const { requirementsMapped, ...restOfFi } = fi; - return { - ...restOfFi, - controls: Array.from(controlsMap.values()), - }; - }); + return { + ...restOfFi, + controls: Array.from(controlsMap.values()), + }; + }); - return frameworksWithControls; - }, + return frameworksWithControls; + }, ); diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/data/getFrameworkWithComplianceScores.ts b/apps/app/src/app/(app)/[orgId]/frameworks/data/getFrameworkWithComplianceScores.ts index 7542880996..bdea0b52b7 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/data/getFrameworkWithComplianceScores.ts +++ b/apps/app/src/app/(app)/[orgId]/frameworks/data/getFrameworkWithComplianceScores.ts @@ -1,20 +1,20 @@ "use server"; import { - Control, - // type Artifact, // Removed Artifact - type Policy, // Policy might still be useful if full Policy objects were ever passed, but selected fields are more common now - type PolicyStatus, // For the selected policy type - type Task, + Control, + // type Artifact, // Removed Artifact + type Policy, // Policy might still be useful if full Policy objects were ever passed, but selected fields are more common now + type PolicyStatus, // For the selected policy type + type Task, } from "@comp/db/types"; import { FrameworkInstanceWithComplianceScore } from "../components/types"; import { FrameworkInstanceWithControls } from "../types"; // This now has policies with selected fields // Define the type for the policies array based on the select in FrameworkInstanceWithControls type SelectedPolicy = { - id: string; - name: string; - status: PolicyStatus; + id: string; + name: string; + status: PolicyStatus; }; /** @@ -24,30 +24,28 @@ type SelectedPolicy = { * @returns boolean indicating if all policies and tasks are compliant */ const isControlCompliant = ( - policies: SelectedPolicy[], // Use the specific selected type - tasks: Task[], + policies: SelectedPolicy[], // Use the specific selected type + tasks: Task[], ) => { - // If there are no policies, the control is not compliant (or has no policy evidence) - if (!policies || policies.length === 0) { - // Depending on business logic, an empty policies array might mean non-compliant or N/A. - // For now, sticking to original logic of false if empty. - return false; - } + // If there are no policies, the control is not compliant (or has no policy evidence) + if (!policies || policies.length === 0) { + // Depending on business logic, an empty policies array might mean non-compliant or N/A. + // For now, sticking to original logic of false if empty. + return false; + } - const totalPolicies = policies.length; - const completedPolicies = policies.filter((policy) => { - return policy.status === "published"; // Directly check status of the selected policy - }).length; + const totalPolicies = policies.length; + const completedPolicies = policies.filter((policy) => { + return policy.status === "published"; // Directly check status of the selected policy + }).length; - const totalTasks = tasks.length; - const completedTasks = tasks.filter( - (task) => task.status === "done", - ).length; + const totalTasks = tasks.length; + const completedTasks = tasks.filter((task) => task.status === "done").length; - return ( - completedPolicies === totalPolicies && - (totalTasks === 0 || completedTasks === totalTasks) - ); + return ( + completedPolicies === totalPolicies && + (totalTasks === 0 || completedTasks === totalTasks) + ); }; /** @@ -56,41 +54,41 @@ const isControlCompliant = ( * @returns Array of frameworks with compliance percentages */ export async function getFrameworkWithComplianceScores({ - frameworksWithControls, - tasks, + frameworksWithControls, + tasks, }: { - frameworksWithControls: FrameworkInstanceWithControls[]; // This type defines control.policies as SelectedPolicy[] - tasks: (Task & { controls: Control[] })[]; + frameworksWithControls: FrameworkInstanceWithControls[]; // This type defines control.policies as SelectedPolicy[] + tasks: (Task & { controls: Control[] })[]; }): Promise { - // Calculate compliance for each framework - const frameworksWithComplianceScores = frameworksWithControls.map( - (frameworkInstance) => { - // Get all controls for this framework - const controls = frameworkInstance.controls; + // Calculate compliance for each framework + const frameworksWithComplianceScores = frameworksWithControls.map( + (frameworkInstance) => { + // Get all controls for this framework + const controls = frameworkInstance.controls; - console.log({controls}); + console.log({ controls }); - // Calculate compliance percentage - const totalControls = controls.length; - const compliantControls = controls.filter((control) => { - const controlTasks = tasks.filter((task) => - task.controls.some((c) => c.id === control.id), - ); - // control.policies here matches SelectedPolicy[] from FrameworkInstanceWithControls - return isControlCompliant(control.policies, controlTasks); - }).length; + // Calculate compliance percentage + const totalControls = controls.length; + const compliantControls = controls.filter((control) => { + const controlTasks = tasks.filter((task) => + task.controls.some((c) => c.id === control.id), + ); + // control.policies here matches SelectedPolicy[] from FrameworkInstanceWithControls + return isControlCompliant(control.policies, controlTasks); + }).length; - const compliance = - totalControls > 0 - ? Math.round((compliantControls / totalControls) * 100) - : 0; + const compliance = + totalControls > 0 + ? Math.round((compliantControls / totalControls) * 100) + : 0; - return { - frameworkInstance, - complianceScore: compliance, - }; - }, - ); + return { + frameworkInstance, + complianceScore: compliance, + }; + }, + ); - return frameworksWithComplianceScores; + return frameworksWithComplianceScores; } diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/data/getSingleFrameworkInstanceWithControls.ts b/apps/app/src/app/(app)/[orgId]/frameworks/data/getSingleFrameworkInstanceWithControls.ts index 9b47365c58..7ac7221037 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/data/getSingleFrameworkInstanceWithControls.ts +++ b/apps/app/src/app/(app)/[orgId]/frameworks/data/getSingleFrameworkInstanceWithControls.ts @@ -5,68 +5,66 @@ import type { FrameworkInstanceWithControls } from "../types"; import type { Control, PolicyStatus, RequirementMap } from "@comp/db/types"; export const getSingleFrameworkInstanceWithControls = async ({ - organizationId, - frameworkInstanceId, + organizationId, + frameworkInstanceId, }: { - organizationId: string; - frameworkInstanceId: string; + organizationId: string; + frameworkInstanceId: string; }): Promise => { - const frameworkInstanceFromDb = await db.frameworkInstance.findUnique({ - where: { - organizationId, - id: frameworkInstanceId, - }, - include: { - framework: true, - requirementsMapped: { - include: { - control: { - include: { - policies: { - select: { - id: true, - name: true, - status: true, - }, - }, - requirementsMapped: true, - }, - }, - }, - }, - }, - }); + const frameworkInstanceFromDb = await db.frameworkInstance.findUnique({ + where: { + organizationId, + id: frameworkInstanceId, + }, + include: { + framework: true, + requirementsMapped: { + include: { + control: { + include: { + policies: { + select: { + id: true, + name: true, + status: true, + }, + }, + requirementsMapped: true, + }, + }, + }, + }, + }, + }); - if (!frameworkInstanceFromDb) { - return null; - } + if (!frameworkInstanceFromDb) { + return null; + } - const controlsMap = new Map< - string, - Control & { - policies: Array<{ id: string; name: string; status: PolicyStatus }>; - requirementsMapped: RequirementMap[]; - } - >(); + const controlsMap = new Map< + string, + Control & { + policies: Array<{ id: string; name: string; status: PolicyStatus }>; + requirementsMapped: RequirementMap[]; + } + >(); - frameworkInstanceFromDb.requirementsMapped.forEach((rm) => { - if (rm.control) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { requirementsMapped: _, ...controlData } = rm.control; - if (!controlsMap.has(rm.control.id)) { - controlsMap.set(rm.control.id, { - ...controlData, - policies: rm.control.policies || [], - requirementsMapped: rm.control.requirementsMapped || [], - }); - } - } - }); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { requirementsMapped, ...restOfFi } = frameworkInstanceFromDb; + frameworkInstanceFromDb.requirementsMapped.forEach((rm) => { + if (rm.control) { + const { requirementsMapped: _, ...controlData } = rm.control; + if (!controlsMap.has(rm.control.id)) { + controlsMap.set(rm.control.id, { + ...controlData, + policies: rm.control.policies || [], + requirementsMapped: rm.control.requirementsMapped || [], + }); + } + } + }); + const { requirementsMapped, ...restOfFi } = frameworkInstanceFromDb; - return { - ...restOfFi, - controls: Array.from(controlsMap.values()), - }; + return { + ...restOfFi, + controls: Array.from(controlsMap.values()), + }; }; diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/error.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/error.tsx index 473e472fea..2b6763d71b 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/error.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/error.tsx @@ -3,25 +3,22 @@ import { useEffect } from "react"; export default function ErrorPage({ - error, - reset, + error, + reset, }: { - error: Error & { digest?: string }; - reset: () => void; + error: Error & { digest?: string }; + reset: () => void; }) { - useEffect(() => { - console.error( - "app/(app)/(dashboard)/[orgId]/frameworks/error.tsx", - error, - ); - }, [error]); + useEffect(() => { + console.error("app/(app)/(dashboard)/[orgId]/frameworks/error.tsx", error); + }, [error]); - return ( -
-

Something went wrong!

- -
- ); + return ( +
+

Something went wrong!

+ +
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/layout.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/layout.tsx index 05a2a317bb..6911ac6f07 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/layout.tsx @@ -1,11 +1,11 @@ export default async function Layout({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }) { - return ( -
-
{children}
-
- ); + return ( +
+
{children}
+
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/lib/utils.ts b/apps/app/src/app/(app)/[orgId]/frameworks/lib/utils.ts index db7d368aac..4fec3fb291 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/lib/utils.ts +++ b/apps/app/src/app/(app)/[orgId]/frameworks/lib/utils.ts @@ -6,43 +6,43 @@ import { Task } from "@comp/db/types"; // Define the expected structure for policies passed to getControlStatus // This should match the data structure provided by the calling code (e.g., from a Prisma select) type SelectedPolicy = { - // Assuming at least status is present. Add other fields like id, name if available and needed. - status: PolicyStatus | null; // Allowing null status based on original ArtifactWithRelations + // Assuming at least status is present. Add other fields like id, name if available and needed. + status: PolicyStatus | null; // Allowing null status based on original ArtifactWithRelations }; // Function to determine control status based on policies and tasks export function getControlStatus( - policies: SelectedPolicy[], // Use the defined type for policies - tasks: (Task & { controls: Control[] })[], // tasks parameter seems fine - controlId: string, // controlId seems fine, used for filtering tasks + policies: SelectedPolicy[], // Use the defined type for policies + tasks: (Task & { controls: Control[] })[], // tasks parameter seems fine + controlId: string, // controlId seems fine, used for filtering tasks ): StatusType { - const controlTasks = tasks.filter((task) => - task.controls.some((c) => c.id === controlId), - ); + const controlTasks = tasks.filter((task) => + task.controls.some((c) => c.id === controlId), + ); - // All policies are draft or none - const allPoliciesDraft = // Renamed from allArtifactsDraft - !policies.length || - policies.every( - (policy) => policy.status === "draft", // Simplified from artifact.policy.status - ); - // All tasks are todo or none - const allTasksTodo = - !controlTasks.length || - controlTasks.every((task) => task.status === "todo"); + // All policies are draft or none + const allPoliciesDraft = // Renamed from allArtifactsDraft + !policies.length || + policies.every( + (policy) => policy.status === "draft", // Simplified from artifact.policy.status + ); + // All tasks are todo or none + const allTasksTodo = + !controlTasks.length || + controlTasks.every((task) => task.status === "todo"); - // All policies are published (and there are policies) AND all tasks are done (or no tasks) - const allPoliciesPublished = // Renamed from allArtifactsPublished - policies.length > 0 && - policies.every( - (policy) => policy.status === "published", // Simplified from artifact.policy.status - ); - const allTasksDone = - controlTasks.length > 0 && - controlTasks.every((task) => task.status === "done"); + // All policies are published (and there are policies) AND all tasks are done (or no tasks) + const allPoliciesPublished = // Renamed from allArtifactsPublished + policies.length > 0 && + policies.every( + (policy) => policy.status === "published", // Simplified from artifact.policy.status + ); + const allTasksDone = + controlTasks.length > 0 && + controlTasks.every((task) => task.status === "done"); - if (allPoliciesPublished && (controlTasks.length === 0 || allTasksDone)) - return "completed"; - if (allPoliciesDraft && allTasksTodo) return "not_started"; - return "in_progress"; + if (allPoliciesPublished && (controlTasks.length === 0 || allTasksDone)) + return "completed"; + if (allPoliciesDraft && allTasksTodo) return "not_started"; + return "in_progress"; } diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/loading.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/loading.tsx index 101dbcbc2b..51223506c3 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/loading.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/loading.tsx @@ -3,51 +3,43 @@ import { Progress } from "@comp/ui/progress"; import { Skeleton } from "@comp/ui/skeleton"; export default function Loading() { - return ( -
-
- {/* Framework Cards */} - {[1, 2, 3].map((index) => ( - - -
-
-
-
- {" "} - {/* Framework name */} - {" "} - {/* Compliance percentage */} -
- -
-
+ return ( +
+
+ {/* Framework Cards */} + {[1, 2, 3].map((index) => ( + + +
+
+
+
+ {/* Framework name */} + {" "} + {/* Compliance percentage */} +
+ +
+
-
-
- {" "} - {/* Controls label */} - {" "} - {/* Controls count */} -
-
- {" "} - {/* Completed label */} - {" "} - {/* Completed count */} -
-
-
-
-
- ))} -
-
- ); +
+
+ {/* Controls label */} + {/* Controls count */} +
+
+ {/* Completed label */} + {/* Completed count */} +
+
+
+
+
+ ))} +
+
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/page.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/page.tsx index 1fc7733665..12173d7186 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/page.tsx @@ -8,70 +8,68 @@ import { getAllFrameworkInstancesWithControls } from "./data/getAllFrameworkInst import { db } from "@comp/db"; export async function generateMetadata() { - return { - title: "Frameworks", - }; + return { + title: "Frameworks", + }; } export default async function DashboardPage() { - const session = await auth.api.getSession({ - headers: await headers(), - }); + const session = await auth.api.getSession({ + headers: await headers(), + }); - const organizationId = session?.session.activeOrganizationId; + const organizationId = session?.session.activeOrganizationId; - if (!organizationId) { - redirect("/"); - } + if (!organizationId) { + redirect("/"); + } - const tasks = await getControlTasks(); - const frameworksWithControls = await getAllFrameworkInstancesWithControls({ - organizationId, - }); + const tasks = await getControlTasks(); + const frameworksWithControls = await getAllFrameworkInstancesWithControls({ + organizationId, + }); - const allFrameworks = await db.frameworkEditorFramework.findMany({ - where: { - visible: true, - }, - }); + const allFrameworks = await db.frameworkEditorFramework.findMany({ + where: { + visible: true, + }, + }); - return ( - - - - ); + return ( + + + + ); } const getControlTasks = cache(async () => { - const session = await auth.api.getSession({ - headers: await headers(), - }); + const session = await auth.api.getSession({ + headers: await headers(), + }); - const organizationId = session?.session.activeOrganizationId; + const organizationId = session?.session.activeOrganizationId; - if (!organizationId) { - return []; - } + if (!organizationId) { + return []; + } - const tasks = await db.task.findMany({ - where: { - organizationId, - controls: { - some: { - organizationId, - }, - }, - }, - include: { - controls: true, - }, - }); + const tasks = await db.task.findMany({ + where: { + organizationId, + controls: { + some: { + organizationId, + }, + }, + }, + include: { + controls: true, + }, + }); - return tasks; + return tasks; }); diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/types.ts b/apps/app/src/app/(app)/[orgId]/frameworks/types.ts index 188c6021a0..89ad4626d8 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/types.ts +++ b/apps/app/src/app/(app)/[orgId]/frameworks/types.ts @@ -1,20 +1,20 @@ import { - Control, - FrameworkInstance, - Policy, - RequirementMap, - FrameworkEditorFramework, - PolicyStatus, + Control, + FrameworkInstance, + Policy, + RequirementMap, + FrameworkEditorFramework, + PolicyStatus, } from "@comp/db/types"; export type FrameworkInstanceWithControls = FrameworkInstance & { - framework: FrameworkEditorFramework; - controls: (Control & { - policies: Array<{ - id: string; - name: string; - status: PolicyStatus; - }>; - requirementsMapped: RequirementMap[]; - })[]; + framework: FrameworkEditorFramework; + controls: (Control & { + policies: Array<{ + id: string; + name: string; + status: PolicyStatus; + }>; + requirementsMapped: RequirementMap[]; + })[]; }; diff --git a/apps/app/src/app/(app)/[orgId]/integrations/layout.tsx b/apps/app/src/app/(app)/[orgId]/integrations/layout.tsx index 4b2c2b9eaa..bd787fc6bd 100644 --- a/apps/app/src/app/(app)/[orgId]/integrations/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/integrations/layout.tsx @@ -1,7 +1,7 @@ export default async function IntegrationsLayout({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }) { - return children; + return children; } diff --git a/apps/app/src/app/(app)/[orgId]/integrations/loading.tsx b/apps/app/src/app/(app)/[orgId]/integrations/loading.tsx index 9ae5f1e0e2..d87a80c28b 100644 --- a/apps/app/src/app/(app)/[orgId]/integrations/loading.tsx +++ b/apps/app/src/app/(app)/[orgId]/integrations/loading.tsx @@ -1,27 +1,27 @@ import { Skeleton } from "@comp/ui/skeleton"; export default function Loading() { - return ( -
-
- - -
+ return ( +
+
+ + +
- {/* Skeleton for IntegrationsServer - Assuming a grid layout */} -
- {[...Array(6)].map((_, i) => ( -
-
- - -
- - - -
- ))} + {/* Skeleton for IntegrationsServer - Assuming a grid layout */} +
+ {[...Array(6)].map((_, i) => ( +
+
+ +
-
- ); + + + +
+ ))} +
+
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/integrations/page.tsx b/apps/app/src/app/(app)/[orgId]/integrations/page.tsx index 2deecb1796..868fed3edc 100644 --- a/apps/app/src/app/(app)/[orgId]/integrations/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/integrations/page.tsx @@ -7,37 +7,37 @@ import { headers } from "next/headers"; import { redirect } from "next/navigation"; export default async function IntegrationsPage() { - const session = await auth.api.getSession({ - headers: await headers(), - }); + const session = await auth.api.getSession({ + headers: await headers(), + }); - if (!session?.session.activeOrganizationId) { - return redirect("/"); - } + if (!session?.session.activeOrganizationId) { + return redirect("/"); + } - const [organization] = await Promise.all([ - db.organization.findUnique({ - where: { - id: session?.session.activeOrganizationId ?? "", - }, - }), - ]); + const [organization] = await Promise.all([ + db.organization.findUnique({ + where: { + id: session?.session.activeOrganizationId ?? "", + }, + }), + ]); - if (!organization) { - return redirect("/"); - } + if (!organization) { + return redirect("/"); + } - return ( -
- + return ( +
+ - -
- ); + +
+ ); } export async function generateMetadata(): Promise { - return { - title: "Integrations", - }; + return { + title: "Integrations", + }; } diff --git a/apps/app/src/app/(app)/[orgId]/layout.tsx b/apps/app/src/app/(app)/[orgId]/layout.tsx index dad159421b..e14829150b 100644 --- a/apps/app/src/app/(app)/[orgId]/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/layout.tsx @@ -10,66 +10,66 @@ import { OnboardingTracker } from "./components/OnboardingTracker"; import { db } from "@comp/db"; const HotKeys = dynamic( - () => import("@/components/hot-keys").then((mod) => mod.HotKeys), - { - ssr: true, - }, + () => import("@/components/hot-keys").then((mod) => mod.HotKeys), + { + ssr: true, + }, ); export default async function Layout({ - children, - params, + children, + params, }: { - children: React.ReactNode; - params: Promise<{ orgId: string }>; + children: React.ReactNode; + params: Promise<{ orgId: string }>; }) { - const { orgId: requestedOrgId } = await params; + const { orgId: requestedOrgId } = await params; - const cookieStore = await cookies(); - const isCollapsed = cookieStore.get("sidebar-collapsed")?.value === "true"; - const publicAccessToken = cookieStore.get("publicAccessToken")?.value; + const cookieStore = await cookies(); + const isCollapsed = cookieStore.get("sidebar-collapsed")?.value === "true"; + const publicAccessToken = cookieStore.get("publicAccessToken")?.value; - const currentOrganization = await getCurrentOrganization({ - requestedOrgId, - }); + const currentOrganization = await getCurrentOrganization({ + requestedOrgId, + }); - const onboarding = await db.onboarding.findFirst({ - where: { - organizationId: currentOrganization?.id, - }, - }); + const onboarding = await db.onboarding.findFirst({ + where: { + organizationId: currentOrganization?.id, + }, + }); - const isOnboardingRunning = - !!onboarding?.triggerJobId && !onboarding.completed; - const navbarHeight = 69 + 1; // 1 for border - const onboardingHeight = 132 + 1; // 1 for border + const isOnboardingRunning = + !!onboarding?.triggerJobId && !onboarding.completed; + const navbarHeight = 69 + 1; // 1 for border + const onboardingHeight = 132 + 1; // 1 for border - const pixelsOffset = isOnboardingRunning - ? navbarHeight + onboardingHeight - : navbarHeight; + const pixelsOffset = isOnboardingRunning + ? navbarHeight + onboardingHeight + : navbarHeight; - return ( - - } - isCollapsed={isCollapsed} - > - {onboarding?.triggerJobId && ( - - )} -
-
- {children} -
- - - - - ); + return ( + + } + isCollapsed={isCollapsed} + > + {onboarding?.triggerJobId && ( + + )} +
+
+ {children} +
+ + + + + ); } diff --git a/apps/app/src/app/(app)/[orgId]/page.tsx b/apps/app/src/app/(app)/[orgId]/page.tsx index 462553813e..44b6ab223a 100644 --- a/apps/app/src/app/(app)/[orgId]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/page.tsx @@ -1,11 +1,11 @@ import { redirect } from "next/navigation"; export default async function DashboardPage({ - params, + params, }: { - params: Promise<{ orgId: string }>; + params: Promise<{ orgId: string }>; }) { - const organizationId = (await params).orgId; + const organizationId = (await params).orgId; - return redirect(`/${organizationId}/frameworks`); + return redirect(`/${organizationId}/frameworks`); } diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/get-employee-details.ts b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/get-employee-details.ts index 86757647f4..c9bd950536 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/get-employee-details.ts +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/get-employee-details.ts @@ -8,62 +8,62 @@ import { type AppError, appErrors, employeeDetailsInputSchema } from "../types"; // Type-safe action response export type ActionResponse = Promise< - { success: true; data: T } | { success: false; error: AppError } + { success: true; data: T } | { success: false; error: AppError } >; export const getEmployeeDetails = authActionClient - .schema(employeeDetailsInputSchema) - .metadata({ - name: "get-employee-details", - track: { - event: "get-employee-details", - channel: "server", - }, - }) - .action(async ({ parsedInput }) => { - const { employeeId } = parsedInput; + .schema(employeeDetailsInputSchema) + .metadata({ + name: "get-employee-details", + track: { + event: "get-employee-details", + channel: "server", + }, + }) + .action(async ({ parsedInput }) => { + const { employeeId } = parsedInput; - const session = await auth.api.getSession({ - headers: await headers(), - }); + const session = await auth.api.getSession({ + headers: await headers(), + }); - const organizationId = session?.session.activeOrganizationId; + const organizationId = session?.session.activeOrganizationId; - if (!organizationId) { - throw new Error("Organization ID not found"); - } + if (!organizationId) { + throw new Error("Organization ID not found"); + } - try { - const employee = await db.member.findUnique({ - where: { - id: employeeId, - organizationId, - }, - select: { - id: true, - department: true, - createdAt: true, - isActive: true, - user: true, - }, - }); + try { + const employee = await db.member.findUnique({ + where: { + id: employeeId, + organizationId, + }, + select: { + id: true, + department: true, + createdAt: true, + isActive: true, + user: true, + }, + }); - if (!employee) { - return { - success: false, - error: appErrors.NOT_FOUND.message, - }; - } + if (!employee) { + return { + success: false, + error: appErrors.NOT_FOUND.message, + }; + } - return { - success: true, - data: employee, - }; - } catch (error) { - console.error("Error fetching employee details:", error); - return { - success: false, - error: appErrors.UNEXPECTED_ERROR.message, - }; - } - }); + return { + success: true, + data: employee, + }; + } catch (error) { + console.error("Error fetching employee details:", error); + return { + success: false, + error: appErrors.UNEXPECTED_ERROR.message, + }; + } + }); diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/update-department.ts b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/update-department.ts index 1afe563e52..304a091b10 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/update-department.ts +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/update-department.ts @@ -7,78 +7,78 @@ import type { Departments } from "@comp/db/types"; import { revalidatePath } from "next/cache"; import { headers } from "next/headers"; import { - type AppError, - appErrors, - updateEmployeeDepartmentSchema, + type AppError, + appErrors, + updateEmployeeDepartmentSchema, } from "../types"; export type ActionResponse = Promise< - { success: true; data: T } | { success: false; error: AppError } + { success: true; data: T } | { success: false; error: AppError } >; export const updateEmployeeDepartment = authActionClient - .schema(updateEmployeeDepartmentSchema) - .metadata({ - name: "update-employee-department", - track: { - event: "update-employee-department", - channel: "server", - }, - }) - .action(async ({ parsedInput }): Promise => { - const { employeeId, department } = parsedInput; + .schema(updateEmployeeDepartmentSchema) + .metadata({ + name: "update-employee-department", + track: { + event: "update-employee-department", + channel: "server", + }, + }) + .action(async ({ parsedInput }): Promise => { + const { employeeId, department } = parsedInput; - const session = await auth.api.getSession({ - headers: await headers(), - }); + const session = await auth.api.getSession({ + headers: await headers(), + }); - const organizationId = session?.session.activeOrganizationId; + const organizationId = session?.session.activeOrganizationId; - if (!organizationId) { - return { - success: false, - error: appErrors.UNAUTHORIZED, - }; - } + if (!organizationId) { + return { + success: false, + error: appErrors.UNAUTHORIZED, + }; + } - try { - const employee = await db.member.findUnique({ - where: { - id: employeeId, - organizationId, - }, - }); + try { + const employee = await db.member.findUnique({ + where: { + id: employeeId, + organizationId, + }, + }); - if (!employee) { - return { - success: false, - error: appErrors.NOT_FOUND, - }; - } + if (!employee) { + return { + success: false, + error: appErrors.NOT_FOUND, + }; + } - const updatedEmployee = await db.member.update({ - where: { - id: employeeId, - organizationId, - }, - data: { - department: department as Departments, - }, - }); + const updatedEmployee = await db.member.update({ + where: { + id: employeeId, + organizationId, + }, + data: { + department: department as Departments, + }, + }); - // Revalidate related paths - revalidatePath(`/${organizationId}/people/${employeeId}`); - revalidatePath(`/${organizationId}/people`); + // Revalidate related paths + revalidatePath(`/${organizationId}/people/${employeeId}`); + revalidatePath(`/${organizationId}/people`); - return { - success: true, - data: updatedEmployee, - }; - } catch (error) { - console.error("Error updating employee department:", error); - return { - success: false, - error: appErrors.UNEXPECTED_ERROR, - }; - } - }); + return { + success: true, + data: updatedEmployee, + }; + } catch (error) { + console.error("Error updating employee department:", error); + return { + success: false, + error: appErrors.UNEXPECTED_ERROR, + }; + } + }); diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/update-employee-details.ts b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/update-employee-details.ts index 293e89a360..2e7b15e8b3 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/update-employee-details.ts +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/update-employee-details.ts @@ -9,85 +9,85 @@ import { z } from "zod"; import { appErrors } from "../types"; const schema = z.object({ - employeeId: z.string(), - name: z.string().min(1, "Name is required"), - email: z.string().email("Invalid email address"), + employeeId: z.string(), + name: z.string().min(1, "Name is required"), + email: z.string().email("Invalid email address"), }); export const updateEmployeeDetails = authActionClient - .schema(schema) - .metadata({ - name: "update-employee-details", - track: { - event: "update-employee-details", - channel: "server", - }, - }) - .action( - async ({ - parsedInput, - }): Promise< - { success: true; data: any } | { success: false; error: any } - > => { - const { employeeId, name, email } = parsedInput; + .schema(schema) + .metadata({ + name: "update-employee-details", + track: { + event: "update-employee-details", + channel: "server", + }, + }) + .action( + async ({ + parsedInput, + }): Promise< + { success: true; data: any } | { success: false; error: any } + > => { + const { employeeId, name, email } = parsedInput; - const session = await auth.api.getSession({ - headers: await headers(), - }); + const session = await auth.api.getSession({ + headers: await headers(), + }); - const organizationId = session?.session.activeOrganizationId; + const organizationId = session?.session.activeOrganizationId; - if (!organizationId) { - return { - success: false, - error: appErrors.UNAUTHORIZED, - }; - } + if (!organizationId) { + return { + success: false, + error: appErrors.UNAUTHORIZED, + }; + } - try { - const employee = await db.member.findUnique({ - where: { - id: employeeId, - organizationId, - }, - }); + try { + const employee = await db.member.findUnique({ + where: { + id: employeeId, + organizationId, + }, + }); - if (!employee) { - return { - success: false, - error: appErrors.NOT_FOUND, - }; - } + if (!employee) { + return { + success: false, + error: appErrors.NOT_FOUND, + }; + } - const updatedEmployee = await db.member.update({ - where: { - id: employeeId, - organizationId, - }, - data: { - user: { - update: { - name, - email, - }, - }, - }, - }); + const updatedEmployee = await db.member.update({ + where: { + id: employeeId, + organizationId, + }, + data: { + user: { + update: { + name, + email, + }, + }, + }, + }); - // Revalidate related paths - revalidatePath(`/${organizationId}/people/${employeeId}`); - revalidatePath(`/${organizationId}/people`); + // Revalidate related paths + revalidatePath(`/${organizationId}/people/${employeeId}`); + revalidatePath(`/${organizationId}/people`); - return { - success: true, - data: updatedEmployee, - }; - } catch (error) { - console.error("Error updating employee details:", error); - return { - success: false, - error: appErrors.UNEXPECTED_ERROR, - }; - } - }, - ); + return { + success: true, + data: updatedEmployee, + }; + } catch (error) { + console.error("Error updating employee details:", error); + return { + success: false, + error: appErrors.UNEXPECTED_ERROR, + }; + } + }, + ); diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/update-employee-status.ts b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/update-employee-status.ts index 43e45c748e..40e4c71561 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/update-employee-status.ts +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/actions/update-employee-status.ts @@ -9,79 +9,79 @@ import { z } from "zod"; import { appErrors } from "../types"; const schema = z.object({ - employeeId: z.string(), - isActive: z.boolean(), + employeeId: z.string(), + isActive: z.boolean(), }); export const updateEmployeeStatus = authActionClient - .schema(schema) - .metadata({ - name: "update-employee-status", - track: { - event: "update-employee-status", - channel: "server", - }, - }) - .action( - async ({ - parsedInput, - }): Promise< - { success: true; data: any } | { success: false; error: any } - > => { - const { employeeId, isActive } = parsedInput; + .schema(schema) + .metadata({ + name: "update-employee-status", + track: { + event: "update-employee-status", + channel: "server", + }, + }) + .action( + async ({ + parsedInput, + }): Promise< + { success: true; data: any } | { success: false; error: any } + > => { + const { employeeId, isActive } = parsedInput; - const session = await auth.api.getSession({ - headers: await headers(), - }); + const session = await auth.api.getSession({ + headers: await headers(), + }); - const organizationId = session?.session.activeOrganizationId; + const organizationId = session?.session.activeOrganizationId; - if (!organizationId) { - return { - success: false, - error: appErrors.UNAUTHORIZED, - }; - } + if (!organizationId) { + return { + success: false, + error: appErrors.UNAUTHORIZED, + }; + } - try { - const employee = await db.member.findUnique({ - where: { - id: employeeId, - organizationId, - }, - }); + try { + const employee = await db.member.findUnique({ + where: { + id: employeeId, + organizationId, + }, + }); - if (!employee) { - return { - success: false, - error: appErrors.NOT_FOUND, - }; - } + if (!employee) { + return { + success: false, + error: appErrors.NOT_FOUND, + }; + } - const updatedEmployee = await db.member.update({ - where: { - id: employeeId, - organizationId, - }, - data: { - isActive, - }, - }); + const updatedEmployee = await db.member.update({ + where: { + id: employeeId, + organizationId, + }, + data: { + isActive, + }, + }); - // Revalidate related paths - revalidatePath(`/${organizationId}/people/${employeeId}`); - revalidatePath(`/${organizationId}/people`); + // Revalidate related paths + revalidatePath(`/${organizationId}/people/${employeeId}`); + revalidatePath(`/${organizationId}/people`); - return { - success: true, - data: updatedEmployee, - }; - } catch (error) { - console.error("Error updating employee status:", error); - return { - success: false, - error: appErrors.UNEXPECTED_ERROR, - }; - } - }, - ); + return { + success: true, + data: updatedEmployee, + }; + } catch (error) { + console.error("Error updating employee status:", error); + return { + success: false, + error: appErrors.UNEXPECTED_ERROR, + }; + } + }, + ); 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 5064b8498f..0dd9f6afa8 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 @@ -9,118 +9,116 @@ import { z } from "zod"; import { appErrors } from "../types"; const schema = z.object({ - employeeId: z.string(), - name: z.string().min(1, "Name cannot be empty").optional(), - email: z.string().email("Invalid email format").optional(), - department: z.string().optional(), - isActive: z.boolean().optional(), - createdAt: z.date().optional(), + employeeId: z.string(), + name: z.string().min(1, "Name cannot be empty").optional(), + email: z.string().email("Invalid email format").optional(), + department: z.string().optional(), + isActive: z.boolean().optional(), + createdAt: z.date().optional(), }); export const updateEmployee = authActionClient - .schema(schema) - .metadata({ - name: "update-employee", - track: { - event: "update-employee", - channel: "server", - }, - }) - .action(async ({ parsedInput, ctx }) => { - const { employeeId, name, email, department, isActive, createdAt } = - parsedInput; + .schema(schema) + .metadata({ + name: "update-employee", + track: { + event: "update-employee", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { employeeId, name, email, department, isActive, createdAt } = + parsedInput; - const organizationId = ctx.session.activeOrganizationId; - if (!organizationId) throw new Error(appErrors.UNAUTHORIZED.message); + const organizationId = ctx.session.activeOrganizationId; + if (!organizationId) throw new Error(appErrors.UNAUTHORIZED.message); - const member = await db.member.findUnique({ - where: { - id: employeeId, - organizationId, - }, - include: { user: true }, - }); + const member = await db.member.findUnique({ + where: { + id: employeeId, + organizationId, + }, + include: { user: true }, + }); - if (!member || !member.user) { - throw new Error(appErrors.NOT_FOUND.message); - } + if (!member || !member.user) { + throw new Error(appErrors.NOT_FOUND.message); + } - const memberUpdateData: { - department?: Departments; - isActive?: boolean; - createdAt?: Date; - } = {}; - const userUpdateData: { name?: string; email?: string } = {}; + const memberUpdateData: { + department?: Departments; + isActive?: boolean; + createdAt?: Date; + } = {}; + const userUpdateData: { name?: string; email?: string } = {}; - if (department !== undefined && department !== member.department) { - memberUpdateData.department = department as Departments; - } - if (isActive !== undefined && isActive !== member.isActive) { - memberUpdateData.isActive = isActive; - } - if ( - createdAt !== undefined && - createdAt.toISOString() !== member.createdAt.toISOString() - ) { - memberUpdateData.createdAt = createdAt; - } - if (name !== undefined && name !== member.user.name) { - userUpdateData.name = name; - } - if (email !== undefined && email !== member.user.email) { - userUpdateData.email = email; - } + if (department !== undefined && department !== member.department) { + memberUpdateData.department = department as Departments; + } + if (isActive !== undefined && isActive !== member.isActive) { + memberUpdateData.isActive = isActive; + } + if ( + createdAt !== undefined && + createdAt.toISOString() !== member.createdAt.toISOString() + ) { + memberUpdateData.createdAt = createdAt; + } + if (name !== undefined && name !== member.user.name) { + userUpdateData.name = name; + } + if (email !== undefined && email !== member.user.email) { + userUpdateData.email = email; + } - const hasMemberChanges = Object.keys(memberUpdateData).length > 0; - const hasUserChanges = Object.keys(userUpdateData).length > 0; + const hasMemberChanges = Object.keys(memberUpdateData).length > 0; + const hasUserChanges = Object.keys(userUpdateData).length > 0; - if (!hasMemberChanges && !hasUserChanges) { - return { success: true, data: member }; - } + if (!hasMemberChanges && !hasUserChanges) { + return { success: true, data: member }; + } - try { - let updatedMemberResult = member; + try { + let updatedMemberResult = member; - await db.$transaction(async (tx) => { - if (hasUserChanges) { - await tx.user.update({ - where: { id: member.userId }, - data: userUpdateData, - }); - } + await db.$transaction(async (tx) => { + if (hasUserChanges) { + await tx.user.update({ + where: { id: member.userId }, + data: userUpdateData, + }); + } - if (hasMemberChanges) { - updatedMemberResult = await tx.member.update({ - where: { - id: employeeId, - organizationId, - }, - data: memberUpdateData, - include: { user: true }, - }); - } else if (hasUserChanges) { - updatedMemberResult = await tx.member.findUniqueOrThrow({ - where: { id: member.id }, - include: { user: true }, - }); - } - }); + if (hasMemberChanges) { + updatedMemberResult = await tx.member.update({ + where: { + id: employeeId, + organizationId, + }, + data: memberUpdateData, + include: { user: true }, + }); + } else if (hasUserChanges) { + updatedMemberResult = await tx.member.findUniqueOrThrow({ + where: { id: member.id }, + include: { user: true }, + }); + } + }); - revalidatePath(`/${organizationId}/people/${employeeId}`); - revalidatePath(`/${organizationId}/people`); + revalidatePath(`/${organizationId}/people/${employeeId}`); + revalidatePath(`/${organizationId}/people`); - return { success: true, data: updatedMemberResult }; - } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { - if (error.code === "P2002") { - const targetFields = error.meta?.target as - | string[] - | undefined; - if (targetFields?.includes("email")) { - throw new Error("Email address is already in use."); - } - } - } - throw error; - } - }); + return { success: true, data: updatedMemberResult }; + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (error.code === "P2002") { + const targetFields = error.meta?.target as string[] | undefined; + if (targetFields?.includes("email")) { + throw new Error("Email address is already in use."); + } + } + } + throw error; + } + }); diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EditableDepartment.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EditableDepartment.tsx index 4619a64d50..0faa50bc64 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EditableDepartment.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EditableDepartment.tsx @@ -3,11 +3,11 @@ import type { Departments } from "@comp/db/types"; import { Button } from "@comp/ui/button"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, } from "@comp/ui/select"; import { useAction } from "next-safe-action/hooks"; import { useState } from "react"; @@ -15,61 +15,59 @@ import { toast } from "sonner"; import { updateEmployeeDepartment } from "../actions/update-department"; const DEPARTMENTS = [ - { 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" }, + { 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" }, ]; interface EditableDepartmentProps { - employeeId: string; - currentDepartment: Departments; - onSuccess?: () => void; + employeeId: string; + currentDepartment: Departments; + onSuccess?: () => void; } export function EditableDepartment({ - employeeId, - currentDepartment, - onSuccess, + employeeId, + currentDepartment, + onSuccess, }: EditableDepartmentProps) { - const [department, setDepartment] = useState(currentDepartment); + const [department, setDepartment] = useState(currentDepartment); - const { execute, status } = useAction(updateEmployeeDepartment, { - onSuccess: () => { - toast.success("Department updated successfully"); - onSuccess?.(); - }, - onError: (error) => { - toast.error( - error?.error?.serverError || "Failed to update department", - ); - }, - }); + const { execute, status } = useAction(updateEmployeeDepartment, { + onSuccess: () => { + toast.success("Department updated successfully"); + onSuccess?.(); + }, + onError: (error) => { + toast.error(error?.error?.serverError || "Failed to update department"); + }, + }); - const handleSave = () => { - execute({ employeeId, department }); - }; + const handleSave = () => { + execute({ employeeId, department }); + }; - return ( -
- -
- ); + return ( +
+ +
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EditableDetails.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EditableDetails.tsx index b0e86614f1..4d1ffd39a0 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EditableDetails.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EditableDetails.tsx @@ -7,27 +7,27 @@ import { toast } from "sonner"; import { updateEmployeeDetails } from "../actions/update-employee-details"; interface EditableDetailsProps { - employeeId: string; - currentName: string; - currentEmail: string; - onSuccess?: () => void; + employeeId: string; + currentName: string; + currentEmail: string; + onSuccess?: () => void; } export function EditableDetails({ - employeeId, - currentName, - currentEmail, + employeeId, + currentName, + currentEmail, }: EditableDetailsProps) { - return ( -
-
- Name - {currentName} -
-
- Email - {currentEmail} -
-
- ); + return ( +
+
+ Name + {currentName} +
+
+ Email + {currentEmail} +
+
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EditableStatus.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EditableStatus.tsx index f326e1a8fa..281c717e56 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EditableStatus.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EditableStatus.tsx @@ -1,17 +1,17 @@ "use client"; import { - EMPLOYEE_STATUS_TYPES, - getEmployeeStatusFromBoolean, + EMPLOYEE_STATUS_TYPES, + getEmployeeStatusFromBoolean, } from "@/components/tables/people/employee-status"; import type { EmployeeStatusType } from "@/components/tables/people/employee-status"; import { Button } from "@comp/ui/button"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, } from "@comp/ui/select"; import { useAction } from "next-safe-action/hooks"; import { useState } from "react"; @@ -19,60 +19,58 @@ import { toast } from "sonner"; import { updateEmployeeStatus } from "../actions/update-employee-status"; const STATUS_OPTIONS = [ - { value: "active", label: "Active" }, - { value: "inactive", label: "Inactive" }, + { value: "active", label: "Active" }, + { value: "inactive", label: "Inactive" }, ]; interface EditableStatusProps { - employeeId: string; - currentStatus: boolean; - onSuccess?: () => void; + employeeId: string; + currentStatus: boolean; + onSuccess?: () => void; } export function EditableStatus({ - employeeId, - currentStatus, - onSuccess, + employeeId, + currentStatus, + onSuccess, }: EditableStatusProps) { - const initialStatus = getEmployeeStatusFromBoolean(currentStatus); - const [status, setStatus] = useState(initialStatus); + const initialStatus = getEmployeeStatusFromBoolean(currentStatus); + const [status, setStatus] = useState(initialStatus); - const { execute, status: actionStatus } = useAction(updateEmployeeStatus, { - onSuccess: () => { - toast.success("Employee status updated successfully"); - onSuccess?.(); - }, - onError: (error) => { - toast.error( - error?.error?.serverError || "Failed to update employee status", - ); - }, - }); + const { execute, status: actionStatus } = useAction(updateEmployeeStatus, { + onSuccess: () => { + toast.success("Employee status updated successfully"); + onSuccess?.(); + }, + onError: (error) => { + toast.error( + error?.error?.serverError || "Failed to update employee status", + ); + }, + }); - const handleSave = () => { - const isActive = status === "active"; - execute({ employeeId, isActive }); - }; + const handleSave = () => { + const isActive = status === "active"; + execute({ employeeId, isActive }); + }; - return ( -
- -
- ); + return ( +
+ +
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx new file mode 100644 index 0000000000..ced414c60b --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx @@ -0,0 +1,48 @@ +"use client"; + +import type { TrainingVideo } from "@/lib/data/training-videos"; +import type { + EmployeeTrainingVideoCompletion, + Member, + Policy, + User, +} from "@comp/db/types"; +import { EmployeeDetails } from "./EmployeeDetails"; +import { EmployeeTasks } from "./EmployeeTasks"; +import type { FleetPolicy, Host } from "../../devices/types"; + +interface EmployeeDetailsProps { + employee: Member & { + user: User; + }; + policies: Policy[]; + trainingVideos: (EmployeeTrainingVideoCompletion & { + metadata: TrainingVideo; + })[]; + fleetPolicies: FleetPolicy[]; + host: Host; + isFleetEnabled: boolean; +} + +export function Employee({ + employee, + policies, + trainingVideos, + fleetPolicies, + host, + isFleetEnabled, +}: EmployeeDetailsProps) { + return ( +
+ + +
+ ); +} 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 4b79c923c3..4018dbbf10 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,490 +1,159 @@ "use client"; -import type { EmployeeStatusType } from "@/components/tables/people/employee-status"; -import { formatDate } from "@/utils/format"; -import type { TrainingVideo } from "@/lib/data/training-videos"; -import type { - Departments, - EmployeeTrainingVideoCompletion, - Member, - Policy, - User, -} from "@comp/db/types"; -import { Alert, AlertDescription, AlertTitle } from "@comp/ui/alert"; +import type { Departments, Member, User } from "@comp/db/types"; import { Button } from "@comp/ui/button"; -import { Calendar } from "@comp/ui/calendar"; import { - Card, - CardContent, - CardFooter, - CardHeader, - CardTitle, + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, } from "@comp/ui/card"; -import { cn } from "@comp/ui/cn"; -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@comp/ui/form"; -import { Input } from "@comp/ui/input"; -import { Popover, PopoverContent, PopoverTrigger } from "@comp/ui/popover"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@comp/ui/select"; -import { Skeleton } from "@comp/ui/skeleton"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@comp/ui/tabs"; +import { Form } from "@comp/ui/form"; import { zodResolver } from "@hookform/resolvers/zod"; -import { format } from "date-fns"; -import { AlertCircle, CheckCircle2, Save } from "lucide-react"; -import { CalendarIcon } from "lucide-react"; -import { useAction } from "next-safe-action/hooks"; -import { redirect, useParams } from "next/navigation"; +import { Save } from "lucide-react"; import { useForm } from "react-hook-form"; -import { toast } from "sonner"; import { z } from "zod"; +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"; +import { toast } from "sonner"; +import { useAction } from "next-safe-action/hooks"; import { updateEmployee } from "../actions/update-employee"; -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" }, -]; - -const STATUS_OPTIONS: { value: EmployeeStatusType; label: string }[] = [ - { value: "active", label: "Active" }, - { value: "inactive", label: "Inactive" }, -]; - -// Status priority and type definitions -const EMPLOYEE_STATUS_PRIORITY: EmployeeStatusType[] = [ - "active", - "inactive", -] as const; -type IEmployeeStatusType = (typeof EMPLOYEE_STATUS_PRIORITY)[number]; - -// Status color mapping for UI components -const EMPLOYEE_STATUS_COLORS = { - active: "bg-[var(--chart-open)]", - inactive: "bg-[hsl(var(--destructive))]", -} as const; - -// Status color hex values for charts -export const EMPLOYEE_STATUS_HEX_COLORS: Record = { - inactive: "#ef4444", - active: "#10b981", -}; - // 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(), + 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(), }); -type EmployeeFormValues = z.infer; - -interface EmployeeDetailsProps { - employeeId: string; - employee: Member & { - user: User; - }; - policies: Policy[]; - trainingVideos: (EmployeeTrainingVideoCompletion & { - metadata: TrainingVideo; - })[]; -} - -export function EmployeeDetails({ - employeeId, - employee, - policies, - trainingVideos, -}: EmployeeDetailsProps) { - const { orgId } = useParams<{ orgId: string }>(); - - // Setup form with React Hook Form - 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 { execute, status: actionStatus } = useAction(updateEmployee, { - onSuccess: () => { - toast.success("Employee details updated successfully"); - }, - onError: (error) => { - toast.error( - error?.error?.serverError || "Failed to update employee details", - ); - }, - }); - - if (!employee) return null; - - const onSubmit = async (values: EmployeeFormValues) => { - // Prepare update data - const updateData: { - employeeId: string; - name?: string; - email?: string; - department?: string; - isActive?: boolean; - createdAt?: Date; - } = { employeeId }; - - // Only include changed fields - if (values.name !== employee.user.name) { - updateData.name = values.name; - } - if (values.email !== employee.user.email) { - updateData.email = values.email; - } - if (values.department !== employee.department) { - updateData.department = values.department; - } - if ( - values.createdAt && - values.createdAt.toISOString() !== employee.createdAt.toISOString() - ) { - updateData.createdAt = values.createdAt; - } - - const isActive = values.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); - } else { - // No changes were made - toast.info("No changes to save"); - } - }; - - return ( -
- {/* Employee Details Section */} - - - - Employee Details - -

- Manage employee information and department assignment -

-
-
- - - {/* Personal Info Section */} -
-

- Personal Info -

-
- ( - - Name - - - - - - )} - /> - ( - - Email - - - - - - )} - /> -
-
- - {/* Department & Status Row */} -
- ( - - - Department - - - - - )} - /> - - ( - - - Status - - - - - )} - /> - ( - - - Join Date - - - - - - - - - date > new Date() // Explicitly type the date argument - } - initialFocus - /> - - - - - )} - /> -
-
- - - -
- -
- - {/* Tasks Section */} - - - -
-

Employee Tasks

-

- View and manage employee tasks and their status -

-
-
-
- - - - Policies - Training Videos - - - -
- {policies.length === 0 ? ( -
-

No policies required to sign.

-
- ) : ( - policies.map((policy) => { - const isCompleted = policy.signedBy.includes(employee.id); - - return ( -
-

- {isCompleted ? ( - - ) : ( - - )} - {policy.name} -

-
- ); - }) - )} -
-
- - -
- {trainingVideos.length === 0 ? ( -
-

No training videos required to watch.

-
- ) : ( - trainingVideos.map((video) => { - const isCompleted = video.completedAt !== null; - - return ( -
-

-
- {isCompleted ? ( -
- -
- ) : ( - - )} - {video.metadata.title} -
- {isCompleted && ( - - Completed -{" "} - {video.completedAt && - new Date( - video.completedAt, - ).toLocaleDateString()} - - )} -

-
- ); - }) - )} -
-
-
-
-
-
- ); -} +export type EmployeeFormValues = z.infer; + +export const EmployeeDetails = ({ + employee, +}: { + employee: Member & { + user: User; + }; +}) => { + 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 { execute, status: actionStatus } = useAction(updateEmployee, { + onSuccess: () => { + toast.success("Employee details updated successfully"); + }, + onError: (error) => { + toast.error( + error?.error?.serverError || "Failed to update employee details", + ); + }, + }); + + const onSubmit = async (values: EmployeeFormValues) => { + // Prepare update data + const updateData: { + employeeId: string; + name?: string; + email?: string; + department?: string; + isActive?: boolean; + createdAt?: Date; + } = { employeeId: employee.id }; + + // Only include changed fields + if (values.name !== employee.user.name) { + updateData.name = values.name; + } + if (values.email !== employee.user.email) { + updateData.email = values.email; + } + if (values.department !== employee.department) { + updateData.department = values.department; + } + if ( + values.createdAt && + values.createdAt.toISOString() !== employee.createdAt.toISOString() + ) { + updateData.createdAt = values.createdAt; + } + + const isActive = values.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); + } else { + // No changes were made + toast.info("No changes to save"); + } + }; + + return ( + + + + Employee Details + +

+ Manage employee information and department assignment +

+
+
+ + +
+ + + + + +
+
+ + + +
+ +
+ ); +}; diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeTasks.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeTasks.tsx new file mode 100644 index 0000000000..4e02e0e477 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeTasks.tsx @@ -0,0 +1,170 @@ +import type { TrainingVideo } from "@/lib/data/training-videos"; +import type { + EmployeeTrainingVideoCompletion, + Member, + Policy, + User, +} from "@comp/db/types"; + +import { Card, CardHeader, CardTitle, CardContent } from "@comp/ui/card"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@comp/ui/tabs"; +import { AlertCircle, CheckCircle2, XCircle } from "lucide-react"; +import type { FleetPolicy, Host } from "../../devices/types"; +import { cn } from "@/lib/utils"; + +export const EmployeeTasks = ({ + employee, + policies, + trainingVideos, + host, + fleetPolicies, + isFleetEnabled, +}: { + employee: Member & { + user: User; + }; + policies: Policy[]; + trainingVideos: (EmployeeTrainingVideoCompletion & { + metadata: TrainingVideo; + })[]; + host: Host; + fleetPolicies: FleetPolicy[]; + isFleetEnabled: boolean; +}) => { + return ( + + + +
+

Employee Tasks

+

+ View and manage employee tasks and their status +

+
+
+
+ + + + Policies + Training Videos + {isFleetEnabled && Device} + + + +
+ {policies.length === 0 ? ( +
+

No policies required to sign.

+
+ ) : ( + policies.map((policy) => { + const isCompleted = policy.signedBy.includes(employee.id); + + return ( +
+

+ {isCompleted ? ( + + ) : ( + + )} + {policy.name} +

+
+ ); + }) + )} +
+
+ + +
+ {trainingVideos.length === 0 ? ( +
+

No training videos required to watch.

+
+ ) : ( + trainingVideos.map((video) => { + const isCompleted = video.completedAt !== null; + + return ( +
+

+
+ {isCompleted ? ( +
+ +
+ ) : ( + + )} + {video.metadata.title} +
+ {isCompleted && ( + + Completed -{" "} + {video.completedAt && + new Date(video.completedAt).toLocaleDateString()} + + )} +

+
+ ); + }) + )} +
+
+ + {isFleetEnabled && ( + + {host ? ( + + + {host.computer_name}'s Policies + + + {fleetPolicies.map((policy) => ( +
+

{policy.name}

+ {policy.response === "pass" ? ( +
+ + Pass +
+ ) : ( +
+ + Fail +
+ )} +
+ ))} +
+
+ ) : ( +
+

No device found.

+
+ )} +
+ )} +
+
+
+ ); +}; 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 new file mode 100644 index 0000000000..6babc8289c --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Department.tsx @@ -0,0 +1,66 @@ +import type { Control } from "react-hook-form"; +import type { EmployeeFormValues } from "../EmployeeDetails"; +import { + FormField, + FormItem, + FormLabel, + FormControl, + FormMessage, +} from "@comp/ui/form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@comp/ui/select"; +import type { Departments } from "@comp/db/types"; + +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, +}: { + control: Control; +}) => { + 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 new file mode 100644 index 0000000000..19f76f36cc --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Email.tsx @@ -0,0 +1,40 @@ +import type { Control } from "react-hook-form"; +import type { EmployeeFormValues } from "../EmployeeDetails"; +import { + FormField, + FormItem, + FormLabel, + FormControl, + FormMessage, +} from "@comp/ui/form"; +import { Input } from "@comp/ui/input"; + +export const Email = ({ + control, +}: { + control: Control; +}) => { + 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 new file mode 100644 index 0000000000..1e5c535a46 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/JoinDate.tsx @@ -0,0 +1,67 @@ +import type { Control } from "react-hook-form"; +import type { EmployeeFormValues } from "../EmployeeDetails"; +import { + FormField, + FormItem, + FormLabel, + FormControl, + FormMessage, +} from "@comp/ui/form"; +import { Button } from "@comp/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@comp/ui/popover"; +import { cn } from "@comp/ui/cn"; +import { Calendar } from "@comp/ui/calendar"; +import { format } from "date-fns"; +import { CalendarIcon } from "lucide-react"; + +export const JoinDate = ({ + control, +}: { + control: Control; +}) => { + return ( + ( + + + Join Date + + + + + + + + + date > new Date() // Explicitly type the date argument + } + initialFocus + /> + + + + + )} + /> + ); +}; 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 new file mode 100644 index 0000000000..bc75b71bc0 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Name.tsx @@ -0,0 +1,30 @@ +import type { Control } from "react-hook-form"; +import type { EmployeeFormValues } from "../EmployeeDetails"; +import { + FormField, + FormItem, + FormLabel, + FormControl, + FormMessage, +} from "@comp/ui/form"; +import { Input } from "@comp/ui/input"; + +export const Name = ({ control }: { control: Control }) => { + 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 new file mode 100644 index 0000000000..5719187cdd --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Status.tsx @@ -0,0 +1,77 @@ +import type { Control } from "react-hook-form"; +import type { EmployeeFormValues } from "../EmployeeDetails"; +import { + FormField, + FormItem, + FormLabel, + FormControl, + FormMessage, +} from "@comp/ui/form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@comp/ui/select"; +import { cn } from "@comp/ui/cn"; +import type { EmployeeStatusType } from "@/components/tables/people/employee-status"; + +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: "#ef4444", + active: "#10b981", +}; + +export const Status = ({ + control, +}: { + control: Control; +}) => { + return ( + ( + + + Status + + + + + )} + /> + ); +}; diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/layout.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/layout.tsx index 719148987f..cad255b33d 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/layout.tsx @@ -1,55 +1,52 @@ import { db } from "@comp/db"; import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator, + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, } from "@comp/ui/breadcrumb"; export default async function Layout({ - children, - params, + children, + params, }: { - children: React.ReactNode; - params: { employeeId: string; orgId: string }; + children: React.ReactNode; + params: Promise<{ employeeId: string; orgId: string }>; }) { - const { employeeId, orgId } = params; - const member = await db.member.findUnique({ - where: { - id: employeeId, - }, - select: { - user: { - select: { - name: true, - }, - }, - }, - }); + const { employeeId, orgId } = await params; + const member = await db.member.findUnique({ + where: { + id: employeeId, + }, + select: { + user: { + select: { + name: true, + }, + }, + }, + }); - return ( -
- {member?.user?.name && ( - - - - - {"People"} - - - - - - {member.user.name} - - - - - - )} - {children} -
- ); + return ( +
+ {member?.user?.name && ( + + + + + {"People"} + + + + + {member.user.name} + + + + )} + {children} +
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx index b9d4e2f580..59bfa5855a 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx @@ -1,144 +1,190 @@ import { auth } from "@/utils/auth"; + import { - TrainingVideo, - trainingVideos as trainingVideosData, + type TrainingVideo, + trainingVideos as trainingVideosData, } from "@/lib/data/training-videos"; import { db } from "@comp/db"; -import type { EmployeeTrainingVideoCompletion } from "@comp/db/types"; +import type { EmployeeTrainingVideoCompletion, Member } from "@comp/db/types"; import type { Metadata } from "next"; import { headers } from "next/headers"; import { notFound, redirect } from "next/navigation"; -import { EmployeeDetails } from "./components/EmployeeDetails"; - -export default async function EmployeeDetailsPage({ params }: { params: Promise<{ employeeId: string }> }) { - const { employeeId } = await params; - - const session = await auth.api.getSession({ - headers: await headers(), - }); - - const organizationId = session?.session.activeOrganizationId; - - if (!organizationId) { - redirect("/"); - } - - const policies = await getPoliciesTasks(employeeId); - const employeeTrainingVideos = await getTrainingVideos(employeeId); - const employee = await getEmployee(employeeId); - - // If employee doesn't exist, show 404 page - if (!employee) { - notFound(); - } - - return ( - - ); +import { Employee } from "./components/Employee"; +import { getFleetInstance } from "@/lib/fleet"; +import { getPostHogClient } from "@/app/posthog"; + +export default async function EmployeeDetailsPage({ + params, +}: { + params: Promise<{ employeeId: string }>; +}) { + const { employeeId } = await params; + + const session = await auth.api.getSession({ + headers: await headers(), + }); + + const organizationId = session?.session.activeOrganizationId; + + if (!organizationId) { + redirect("/"); + } + + const policies = await getPoliciesTasks(employeeId); + const employeeTrainingVideos = await getTrainingVideos(employeeId); + const employee = await getEmployee(employeeId); + + // If employee doesn't exist, show 404 page + if (!employee) { + notFound(); + } + + const { fleetPolicies, device } = await getFleetPolicies(employee); + const isFleetEnabled = await getPostHogClient()?.isFeatureEnabled( + "is-fleet-enabled", + session?.session.userId, + ); + + return ( + + ); } export async function generateMetadata(): Promise { - return { - title: "Employee Details", - }; + return { + title: "Employee Details", + }; } const getEmployee = async (employeeId: string) => { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - const organizationId = session?.session.activeOrganizationId; - - if (!organizationId) { - redirect("/"); - } - - const employee = await db.member.findFirst({ - where: { - id: employeeId, - }, - include: { - user: true, - }, - }); - - return employee; + const session = await auth.api.getSession({ + headers: await headers(), + }); + + const organizationId = session?.session.activeOrganizationId; + + if (!organizationId) { + redirect("/"); + } + + const employee = await db.member.findFirst({ + where: { + id: employeeId, + }, + include: { + user: true, + }, + }); + + return employee; }; const getPoliciesTasks = async (employeeId: string) => { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - const organizationId = session?.session.activeOrganizationId; - - if (!organizationId) { - redirect("/"); - } - - const policies = await db.policy.findMany({ - where: { - organizationId: organizationId, - isRequiredToSign: true, - }, - orderBy: { - name: "asc", - }, - }); - - return policies; + const session = await auth.api.getSession({ + headers: await headers(), + }); + + const organizationId = session?.session.activeOrganizationId; + + if (!organizationId) { + redirect("/"); + } + + const policies = await db.policy.findMany({ + where: { + organizationId: organizationId, + isRequiredToSign: true, + }, + orderBy: { + name: "asc", + }, + }); + + return policies; }; const getTrainingVideos = async (employeeId: string) => { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - const organizationId = session?.session.activeOrganizationId; - - if (!organizationId) { - redirect("/"); - } - - const employeeTrainingVideos = - await db.employeeTrainingVideoCompletion.findMany({ - where: { - memberId: employeeId, - }, - orderBy: { - videoId: "asc", - }, - }); - - // Map the db records to include the matching metadata from the training videos data - // Filter out any videos where metadata is not found to ensure type safety - return employeeTrainingVideos - .map((dbVideo) => { - // Find the training video metadata with the matching ID - const videoMetadata = trainingVideosData.find( - (metadataVideo) => metadataVideo.id === dbVideo.videoId, - ); - - // Only return videos that have matching metadata - if (videoMetadata) { - return { - ...dbVideo, - metadata: videoMetadata, - }; - } - return null; - }) - .filter( - ( - video, - ): video is EmployeeTrainingVideoCompletion & { - metadata: TrainingVideo; - } => video !== null, - ); + const session = await auth.api.getSession({ + headers: await headers(), + }); + + const organizationId = session?.session.activeOrganizationId; + + if (!organizationId) { + redirect("/"); + } + + const employeeTrainingVideos = + await db.employeeTrainingVideoCompletion.findMany({ + where: { + memberId: employeeId, + }, + orderBy: { + videoId: "asc", + }, + }); + + // Map the db records to include the matching metadata from the training videos data + // Filter out any videos where metadata is not found to ensure type safety + return employeeTrainingVideos + .map((dbVideo) => { + // Find the training video metadata with the matching ID + const videoMetadata = trainingVideosData.find( + (metadataVideo) => metadataVideo.id === dbVideo.videoId, + ); + + // Only return videos that have matching metadata + if (videoMetadata) { + return { + ...dbVideo, + metadata: videoMetadata, + }; + } + return null; + }) + .filter( + ( + video, + ): video is EmployeeTrainingVideoCompletion & { + metadata: TrainingVideo; + } => video !== null, + ); +}; + +const getFleetPolicies = async (member: Member) => { + const deviceLabelId = member.fleetDmLabelId; + const fleet = await getFleetInstance(); + + if (!deviceLabelId) { + return { fleetPolicies: [], device: null }; + } + + try { + const deviceResponse = await fleet.get(`/labels/${deviceLabelId}/hosts`); + const device = deviceResponse.data.hosts?.[0]; // There should only be one device per label. + + if (!device) { + console.log( + `No host found for device label id: ${deviceLabelId} - member: ${member.id}`, + ); + return { fleetPolicies: [], device: null }; + } + + const deviceWithPolicies = await fleet.get(`/hosts/${device.id}`); + const fleetPolicies = deviceWithPolicies.data.host.policies; + return { fleetPolicies, device }; + } catch (error) { + console.error( + `Failed to get fleet policies for member: ${member.id}`, + error, + ); + return { fleetPolicies: [], device: null }; + } }; diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/types/index.ts b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/types/index.ts index b6965d9602..02dbdc39e8 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/types/index.ts +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/types/index.ts @@ -1,60 +1,60 @@ import { z } from "zod"; export const employeeTaskSchema = z.object({ - id: z.string(), - status: z.enum(["assigned", "in_progress", "completed", "overdue"]), - requiredTask: z.object({ - id: z.string(), - name: z.string(), - description: z.string().nullable(), - }), + id: z.string(), + status: z.enum(["assigned", "in_progress", "completed", "overdue"]), + requiredTask: z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullable(), + }), }); export const employeeDetailsSchema = z.object({ - id: z.string(), - department: z.string().nullable(), - createdAt: z.date(), - isActive: z.boolean(), - user: z.object({ - id: z.string(), - name: z.string().nullable(), - email: z.string(), - }), - employeeTasks: z.array(employeeTaskSchema).optional(), + id: z.string(), + department: z.string().nullable(), + createdAt: z.date(), + isActive: z.boolean(), + user: z.object({ + id: z.string(), + name: z.string().nullable(), + email: z.string(), + }), + employeeTasks: z.array(employeeTaskSchema).optional(), }); export const employeeDetailsInputSchema = z.object({ - employeeId: z.string(), + employeeId: z.string(), }); export const updateEmployeeDepartmentSchema = z.object({ - employeeId: z.string(), - department: z.enum(["admin", "gov", "hr", "it", "itsm", "qms", "none"]), + employeeId: z.string(), + department: z.enum(["admin", "gov", "hr", "it", "itsm", "qms", "none"]), }); export type EmployeeTask = z.infer; export type EmployeeDetails = z.infer; export type EmployeeDetailsInput = z.infer; export type UpdateEmployeeDepartmentInput = z.infer< - typeof updateEmployeeDepartmentSchema + typeof updateEmployeeDepartmentSchema >; export type AppError = { - code: "NOT_FOUND" | "UNAUTHORIZED" | "UNEXPECTED_ERROR"; - message: string; + code: "NOT_FOUND" | "UNAUTHORIZED" | "UNEXPECTED_ERROR"; + message: string; }; export const appErrors = { - NOT_FOUND: { - code: "NOT_FOUND" as const, - message: "Employee not found", - }, - UNAUTHORIZED: { - code: "UNAUTHORIZED" as const, - message: "You are not authorized to view this employee", - }, - UNEXPECTED_ERROR: { - code: "UNEXPECTED_ERROR" as const, - message: "An unexpected error occurred", - }, + NOT_FOUND: { + code: "NOT_FOUND" as const, + message: "Employee not found", + }, + UNAUTHORIZED: { + code: "UNAUTHORIZED" as const, + message: "You are not authorized to view this employee", + }, + UNEXPECTED_ERROR: { + code: "UNEXPECTED_ERROR" as const, + message: "An unexpected error occurred", + }, } as const; 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 3b4a618194..1078409fec 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 @@ -4,43 +4,43 @@ import { auth } from "@/utils/auth"; import { db } from "@comp/db"; export const addEmployeeWithoutInvite = async ({ - email, - organizationId, + email, + organizationId, }: { - email: string; - organizationId: string; + email: string; + organizationId: string; }) => { - try { - let userId = ""; - const existingUser = await db.user.findUnique({ - where: { - email, - }, - }); + try { + let userId = ""; + const existingUser = await db.user.findUnique({ + where: { + email, + }, + }); - if (!existingUser) { - const newUser = await db.user.create({ - data: { - emailVerified: false, - email, - name: email.split("@")[0], - }, - }); + if (!existingUser) { + const newUser = await db.user.create({ + data: { + emailVerified: false, + email, + name: email.split("@")[0], + }, + }); - userId = newUser.id; - } + userId = newUser.id; + } - const member = await auth.api.addMember({ - body: { - userId: existingUser?.id ?? userId, - organizationId, - role: "employee", - }, - }); + const member = await auth.api.addMember({ + body: { + userId: existingUser?.id ?? userId, + organizationId, + role: "employee", + }, + }); - return { success: true, data: member }; - } catch (error) { - console.error("Error adding employee:", error); - return { success: false, error: "Failed to add employee" }; - } + return { success: true, data: member }; + } catch (error) { + console.error("Error adding employee:", error); + return { success: false, error: "Failed to add employee" }; + } }; diff --git a/apps/app/src/app/(app)/[orgId]/people/all/actions/bulkInviteMembers.ts b/apps/app/src/app/(app)/[orgId]/people/all/actions/bulkInviteMembers.ts index 3bc3ed3ef8..b0f0b99999 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/actions/bulkInviteMembers.ts +++ b/apps/app/src/app/(app)/[orgId]/people/all/actions/bulkInviteMembers.ts @@ -15,197 +15,176 @@ type InviteRole = (typeof availableRoles)[number]; const DEFAULT_ROLE: InviteRole = "employee"; // Define default role here too const manualInviteSchema = z.object({ - email: emailSchema, - role: z.enum(availableRoles), + email: emailSchema, + role: z.enum(availableRoles), }); const manualInvitesSchema = z.array(manualInviteSchema); // --- Result Type --- interface BulkInviteResult { - successfulInvites: number; - failedItems: { - input: string | { email: string; role: InviteRole }; - error: string; - }[]; + successfulInvites: number; + failedItems: { + input: string | { email: string; role: InviteRole }; + error: string; + }[]; } // --- Server Action (Accepts FormData) --- export async function bulkInviteMembers( - formData: FormData, + formData: FormData, ): Promise> { - // Manually get session and check auth - const session = await auth.api.getSession({ headers: await headers() }); - if (!session?.session) { - return { success: false, error: "Authentication required." }; - } - const organizationId = session.session.activeOrganizationId; - const userId = session.session.userId; - - if (!organizationId) { - return { success: false, error: "Organization not found" }; - } - - const results: BulkInviteResult = { - successfulInvites: 0, - failedItems: [], - }; - - const submissionType = formData.get("type") as string; - - try { - if (submissionType === "csv") { - const file = formData.get("csvFile") as File | null; - if (!file || !(file instanceof File)) { - return { - success: false, - error: "CSV file not provided or invalid.", - }; - } - if (file.size > 5 * 1024 * 1024) { - // 5MB check - return { - success: false, - error: "CSV file size exceeds 5MB limit.", - }; - } - if (file.type !== "text/csv") { - return { - success: false, - error: "Invalid file type. Only CSV is allowed.", - }; - } - - const csvText = await file.text(); - const rows = csvText.split("\n").slice(1); // Simple split, skip header - - if ( - rows.length === 0 || - (rows.length === 1 && rows[0].trim() === "") - ) { - return { - success: false, - error: "CSV file is empty or contains only a header.", - }; - } - - for (const row of rows) { - const trimmedRow = row.trim(); - if (!trimmedRow) continue; // Skip empty lines - - const [emailStr, roleStr] = trimmedRow - .split(",") - .map((s) => s.trim()); - const rowInput = `CSV Row: ${emailStr || "[missing]"}, ${roleStr || "[missing]"}`; - - try { - // Validate email - const email = emailSchema.parse(emailStr); - - // Validate or default role - let role: InviteRole = DEFAULT_ROLE; // Use defined default - if (roleStr) { - const parsedRole = z - .enum(availableRoles) - .safeParse(roleStr.toLowerCase()); - if (parsedRole.success) { - role = parsedRole.data; - } else { - throw new Error( - `Invalid role specified: ${roleStr}`, - ); - } - } - - await authClient.organization.inviteMember({ email, role }); - results.successfulInvites += 1; - } catch (error) { - console.error( - `Error processing CSV row ${rowInput}:`, - error, - ); - results.failedItems.push({ - input: rowInput, - error: - error instanceof Error - ? error.message - : "Processing failed", - }); - } - } - } else if (submissionType === "manual") { - const invitesJson = formData.get("invites") as string | null; - if (!invitesJson) { - return { - success: false, - error: "Manual invite data not provided.", - }; - } - - let manualInvites: z.infer; - try { - manualInvites = manualInvitesSchema.parse( - JSON.parse(invitesJson), - ); - } catch (parseError) { - console.error("Error parsing manual invites JSON:", parseError); - // Don't include validationErrors field, just return a general error - return { - success: false, - error: "Invalid format for manual invite data.", - }; - } - - if (manualInvites.length === 0) { - return { - success: false, - error: "No manual invites submitted.", - }; - } - - for (const invite of manualInvites) { - try { - // Email/Role already validated by Zod parse above - await authClient.organization.inviteMember(invite); - results.successfulInvites += 1; - } catch (error) { - console.error( - `Error inviting manual member ${invite.email}:`, - error, - ); - results.failedItems.push({ - input: invite, - error: - error instanceof Error - ? error.message - : "Invite failed", - }); - } - } - } else { - return { success: false, error: "Invalid submission type." }; - } - - // Revalidate only if changes were made - if (results.successfulInvites > 0) { - revalidatePath(`/${organizationId}/settings/users`); - revalidateTag(`user_${userId}`); // Use userId from manually fetched session - } - - // Determine overall success - if (results.successfulInvites > 0 || results.failedItems.length === 0) { - return { success: true, data: results }; - } - return { - success: false, - error: "All invitations failed.", - data: results, - }; - } catch (error) { - // Catch unexpected errors during processing (e.g., file reading) - console.error("Unexpected error in bulkInviteMembers:", error); - return { - success: false, - error: "An unexpected server error occurred processing the invites.", - }; - } + // Manually get session and check auth + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.session) { + return { success: false, error: "Authentication required." }; + } + const organizationId = session.session.activeOrganizationId; + const userId = session.session.userId; + + if (!organizationId) { + return { success: false, error: "Organization not found" }; + } + + const results: BulkInviteResult = { + successfulInvites: 0, + failedItems: [], + }; + + const submissionType = formData.get("type") as string; + + try { + if (submissionType === "csv") { + const file = formData.get("csvFile") as File | null; + if (!file || !(file instanceof File)) { + return { + success: false, + error: "CSV file not provided or invalid.", + }; + } + if (file.size > 5 * 1024 * 1024) { + // 5MB check + return { + success: false, + error: "CSV file size exceeds 5MB limit.", + }; + } + if (file.type !== "text/csv") { + return { + success: false, + error: "Invalid file type. Only CSV is allowed.", + }; + } + + const csvText = await file.text(); + const rows = csvText.split("\n").slice(1); // Simple split, skip header + + if (rows.length === 0 || (rows.length === 1 && rows[0].trim() === "")) { + return { + success: false, + error: "CSV file is empty or contains only a header.", + }; + } + + for (const row of rows) { + const trimmedRow = row.trim(); + if (!trimmedRow) continue; // Skip empty lines + + const [emailStr, roleStr] = trimmedRow.split(",").map((s) => s.trim()); + const rowInput = `CSV Row: ${emailStr || "[missing]"}, ${roleStr || "[missing]"}`; + + try { + // Validate email + const email = emailSchema.parse(emailStr); + + // Validate or default role + let role: InviteRole = DEFAULT_ROLE; // Use defined default + if (roleStr) { + const parsedRole = z + .enum(availableRoles) + .safeParse(roleStr.toLowerCase()); + if (parsedRole.success) { + role = parsedRole.data; + } else { + throw new Error(`Invalid role specified: ${roleStr}`); + } + } + + await authClient.organization.inviteMember({ email, role }); + results.successfulInvites += 1; + } catch (error) { + console.error(`Error processing CSV row ${rowInput}:`, error); + results.failedItems.push({ + input: rowInput, + error: error instanceof Error ? error.message : "Processing failed", + }); + } + } + } else if (submissionType === "manual") { + const invitesJson = formData.get("invites") as string | null; + if (!invitesJson) { + return { + success: false, + error: "Manual invite data not provided.", + }; + } + + let manualInvites: z.infer; + try { + manualInvites = manualInvitesSchema.parse(JSON.parse(invitesJson)); + } catch (parseError) { + console.error("Error parsing manual invites JSON:", parseError); + // Don't include validationErrors field, just return a general error + return { + success: false, + error: "Invalid format for manual invite data.", + }; + } + + if (manualInvites.length === 0) { + return { + success: false, + error: "No manual invites submitted.", + }; + } + + for (const invite of manualInvites) { + try { + // Email/Role already validated by Zod parse above + await authClient.organization.inviteMember(invite); + results.successfulInvites += 1; + } catch (error) { + console.error(`Error inviting manual member ${invite.email}:`, error); + results.failedItems.push({ + input: invite, + error: error instanceof Error ? error.message : "Invite failed", + }); + } + } + } else { + return { success: false, error: "Invalid submission type." }; + } + + // Revalidate only if changes were made + if (results.successfulInvites > 0) { + revalidatePath(`/${organizationId}/settings/users`); + revalidateTag(`user_${userId}`); // Use userId from manually fetched session + } + + // Determine overall success + if (results.successfulInvites > 0 || results.failedItems.length === 0) { + return { success: true, data: results }; + } + return { + success: false, + error: "All invitations failed.", + data: results, + }; + } catch (error) { + // Catch unexpected errors during processing (e.g., file reading) + console.error("Unexpected error in bulkInviteMembers:", error); + return { + success: false, + error: "An unexpected server error occurred processing the invites.", + }; + } } diff --git a/apps/app/src/app/(app)/[orgId]/people/all/actions/invalidateMembers.ts b/apps/app/src/app/(app)/[orgId]/people/all/actions/invalidateMembers.ts index 7c0a3fcffc..59bb68fbd9 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/actions/invalidateMembers.ts +++ b/apps/app/src/app/(app)/[orgId]/people/all/actions/invalidateMembers.ts @@ -3,10 +3,10 @@ import { revalidatePath } from "next/cache"; export async function invalidateMembers({ - organizationId, + organizationId, }: { - organizationId: string; + organizationId: string; }) { - // Ensure correct path is used for revalidation - return revalidatePath(`/${organizationId}/settings/users`); + // Ensure correct path is used for revalidation + return revalidatePath(`/${organizationId}/settings/users`); } 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 1725fd5d36..1a554d3641 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 @@ -8,113 +8,111 @@ import { authActionClient } from "@/actions/safe-action"; import type { ActionResponse } from "@/actions/types"; const removeMemberSchema = z.object({ - memberId: z.string(), + memberId: z.string(), }); export const removeMember = authActionClient - .metadata({ - name: "remove-member", - track: { - event: "remove_member", - channel: "organization", - }, - }) - .schema(removeMemberSchema) - .action( - async ({ - parsedInput, - ctx, - }): Promise> => { - if (!ctx.session.activeOrganizationId) { - return { - success: false, - error: "User does not have an organization", - }; - } + .metadata({ + name: "remove-member", + track: { + event: "remove_member", + channel: "organization", + }, + }) + .schema(removeMemberSchema) + .action( + async ({ + parsedInput, + ctx, + }): Promise> => { + if (!ctx.session.activeOrganizationId) { + return { + success: false, + error: "User does not have an organization", + }; + } - const { memberId } = parsedInput; + 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, - }, - }); + try { + // Check if user has admin permissions + const currentUserMember = await db.member.findFirst({ + where: { + organizationId: ctx.session.activeOrganizationId, + userId: ctx.user.id, + }, + }); - if ( - !currentUserMember || - (currentUserMember.role !== "admin" && - currentUserMember.role !== "owner") - ) { - return { - success: false, - error: "You don't have permission to remove members", - }; - } + if ( + !currentUserMember || + (currentUserMember.role !== "admin" && + currentUserMember.role !== "owner") + ) { + return { + success: false, + error: "You don't have permission to remove members", + }; + } - // Check if the target member exists in the organization - const targetMember = await db.member.findFirst({ - where: { - id: memberId, - organizationId: ctx.session.activeOrganizationId, - }, - }); + // Check if the target member exists in the organization + const targetMember = await db.member.findFirst({ + where: { + id: memberId, + organizationId: ctx.session.activeOrganizationId, + }, + }); - if (!targetMember) { - return { - success: false, - error: "Member not found in this organization", - }; - } + if (!targetMember) { + return { + success: false, + error: "Member not found in this organization", + }; + } - // Prevent removing the owner - if (targetMember.role === "owner") { - return { - success: false, - error: "Cannot remove the organization owner", - }; - } + // Prevent removing the owner + if (targetMember.role === "owner") { + return { + success: false, + error: "Cannot remove the organization owner", + }; + } - // Prevent self-removal - if (targetMember.userId === ctx.user.id) { - return { - success: false, - error: "You cannot remove yourself from the organization", - }; - } + // Prevent self-removal + if (targetMember.userId === ctx.user.id) { + return { + success: false, + error: "You cannot remove yourself from the organization", + }; + } - // Remove the member - await db.member.delete({ - where: { - id: memberId, - }, - }); + // Remove the member + await db.member.delete({ + where: { + id: memberId, + }, + }); - // Consider if deleting sessions is still desired here - await db.session.deleteMany({ - where: { - userId: targetMember.userId, - }, - }); + // Consider if deleting sessions is still desired here + await db.session.deleteMany({ + where: { + userId: targetMember.userId, + }, + }); - revalidatePath( - `/${ctx.session.activeOrganizationId}/settings/users`, - ); - revalidateTag(`user_${ctx.user.id}`); + revalidatePath(`/${ctx.session.activeOrganizationId}/settings/users`); + revalidateTag(`user_${ctx.user.id}`); - return { - success: true, - data: { removed: true }, - }; - } catch (error) { - // Log the actual error for better debugging - console.error("Error removing member:", error); - return { - success: false, - error: "Failed to remove member", // Keep generic message for client - }; - } - }, - ); + return { + success: true, + data: { removed: true }, + }; + } catch (error) { + // Log the actual error for better debugging + console.error("Error removing member:", error); + return { + success: false, + error: "Failed to remove member", // Keep generic message for client + }; + } + }, + ); diff --git a/apps/app/src/app/(app)/[orgId]/people/all/actions/revokeInvitation.ts b/apps/app/src/app/(app)/[orgId]/people/all/actions/revokeInvitation.ts index ae0e6f080c..47030457f1 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/actions/revokeInvitation.ts +++ b/apps/app/src/app/(app)/[orgId]/people/all/actions/revokeInvitation.ts @@ -10,71 +10,69 @@ import { authActionClient } from "@/actions/safe-action"; import type { ActionResponse } from "@/actions/types"; const revokeInvitationSchema = z.object({ - invitationId: z.string(), + invitationId: z.string(), }); export const revokeInvitation = authActionClient - .metadata({ - name: "revoke-invitation", - track: { - event: "revoke_invitation", - channel: "organization", - }, - }) - .schema(revokeInvitationSchema) - .action( - async ({ - parsedInput, - ctx, - }): Promise> => { - if (!ctx.session.activeOrganizationId) { - return { - success: false, - error: "User does not have an organization", - }; - } + .metadata({ + name: "revoke-invitation", + track: { + event: "revoke_invitation", + channel: "organization", + }, + }) + .schema(revokeInvitationSchema) + .action( + async ({ + parsedInput, + ctx, + }): Promise> => { + if (!ctx.session.activeOrganizationId) { + return { + success: false, + error: "User does not have an organization", + }; + } - const { invitationId } = parsedInput; + const { invitationId } = parsedInput; - try { - // Check if the invitation exists in the organization - const invitation = await db.invitation.findFirst({ - where: { - id: invitationId, - organizationId: ctx.session.activeOrganizationId, - status: "pending", - }, - }); + try { + // Check if the invitation exists in the organization + const invitation = await db.invitation.findFirst({ + where: { + id: invitationId, + organizationId: ctx.session.activeOrganizationId, + status: "pending", + }, + }); - if (!invitation) { - return { - success: false, - error: "Invitation not found or already accepted", - }; - } + if (!invitation) { + return { + success: false, + error: "Invitation not found or already accepted", + }; + } - // Revoke the invitation by deleting the invitation record - await db.invitation.delete({ - where: { - id: invitationId, - }, - }); + // Revoke the invitation by deleting the invitation record + await db.invitation.delete({ + where: { + id: invitationId, + }, + }); - revalidatePath( - `/${ctx.session.activeOrganizationId}/settings/users`, - ); - revalidateTag(`user_${ctx.user.id}`); + revalidatePath(`/${ctx.session.activeOrganizationId}/settings/users`); + revalidateTag(`user_${ctx.user.id}`); - return { - success: true, - data: { revoked: true }, - }; - } catch (error) { - console.error("Error revoking invitation:", error); - return { - success: false, - error: "Failed to revoke invitation", - }; - } - }, - ); + return { + success: true, + data: { revoked: true }, + }; + } catch (error) { + console.error("Error revoking invitation:", error); + return { + success: false, + error: "Failed to revoke invitation", + }; + } + }, + ); diff --git a/apps/app/src/app/(app)/[orgId]/people/all/actions/updateMemberRole.ts b/apps/app/src/app/(app)/[orgId]/people/all/actions/updateMemberRole.ts index d4987299c1..3b7c15c3f0 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/actions/updateMemberRole.ts +++ b/apps/app/src/app/(app)/[orgId]/people/all/actions/updateMemberRole.ts @@ -11,174 +11,168 @@ import type { ActionResponse } from "@/actions/types"; // Define selectable roles constants here as well for schema consistency const selectableRoles = [ - "admin", - "auditor", - "employee", + "admin", + "auditor", + "employee", ] as const satisfies Readonly; const updateMemberRoleSchema = z.object({ - memberId: z.string(), - // Expect an array of roles, ensuring at least one valid role is provided - roles: z - .array(z.nativeEnum(Role)) - .min(1, { message: "At least one role must be selected." }) - // Ensure owner role cannot be set via this action (should be handled elsewhere) - .refine((roles) => !roles.includes(Role.owner), { - message: "Cannot assign owner role through this action.", - }), - department: z.nativeEnum(Departments).optional(), + memberId: z.string(), + // Expect an array of roles, ensuring at least one valid role is provided + roles: z + .array(z.nativeEnum(Role)) + .min(1, { message: "At least one role must be selected." }) + // Ensure owner role cannot be set via this action (should be handled elsewhere) + .refine((roles) => !roles.includes(Role.owner), { + message: "Cannot assign owner role through this action.", + }), + department: z.nativeEnum(Departments).optional(), }); // Helper to safely parse comma-separated roles string function parseRolesString(rolesStr: string | null | undefined): Role[] { - if (!rolesStr) return []; - return rolesStr - .split(",") - .map((r) => r.trim()) - .filter((r) => r in Role) as Role[]; + if (!rolesStr) return []; + return rolesStr + .split(",") + .map((r) => r.trim()) + .filter((r) => r in Role) as Role[]; } // Helper function to compare role arrays function arraysHaveSameElements(arr1: Role[], arr2: Role[]) { - if (arr1.length !== arr2.length) return false; - const sortedArr1 = [...arr1].sort(); - const sortedArr2 = [...arr2].sort(); - return sortedArr1.every((value, index) => value === sortedArr2[index]); + if (arr1.length !== arr2.length) return false; + const sortedArr1 = [...arr1].sort(); + const sortedArr2 = [...arr2].sort(); + return sortedArr1.every((value, index) => value === sortedArr2[index]); } export const updateMemberRole = authActionClient - .metadata({ - name: "update-member-role", - track: { - event: "update_member_role", - channel: "organization", - }, - }) - .schema(updateMemberRoleSchema) - .action( - async ({ - parsedInput, - ctx, - }): Promise> => { - if (!ctx.session.activeOrganizationId) { - return { - success: false, - error: "User does not have an organization", - }; - } - - const { memberId, roles: newRoles, department } = parsedInput; - const orgId = ctx.session.activeOrganizationId; - const requestingUserId = ctx.user.id; - - try { - // Permission check: User needs to be admin or owner - const currentUserMember = await db.member.findFirst({ - where: { organizationId: orgId, userId: requestingUserId }, - }); - // Check if current user's roles string includes admin or owner - const currentUserRoles = parseRolesString( - currentUserMember?.role, - ); - const isAdminOrOwner = - currentUserRoles.includes(Role.admin) || - currentUserRoles.includes(Role.owner); - - if (!isAdminOrOwner) { - return { - success: false, - error: "You don't have permission to update member roles", - }; - } - - // Get target member - const targetMember = await db.member.findFirst({ - where: { id: memberId, organizationId: orgId }, - }); - - if (!targetMember) { - return { - success: false, - error: "Member not found in this organization", - }; - } - - // Parse the target member's current roles from the string - const currentRoles = parseRolesString(targetMember.role); - - // Prevent changing owner's roles - if (currentRoles.includes(Role.owner)) { - return { - success: false, - error: "Cannot change roles for the organization owner.", - }; - } - - // Check if roles or department actually changed - const rolesChanged = !arraysHaveSameElements( - currentRoles, - newRoles, - ); - const departmentChanged = - department && targetMember.department !== department; - - if (!rolesChanged && !departmentChanged) { - return { - success: true, - data: { updated: false }, - }; // No changes needed - } - - // --- Role Update Logic --- - let newRoleString = targetMember.role; // Start with existing string if only dept changes - - if (rolesChanged) { - console.log( - `Updating roles for member ${targetMember.userId} from ${currentRoles.join(",")} to ${newRoles.join(",")}`, - ); - - // ** PREFERRED: Use authClient methods if available ** - // Example: Calculate rolesToAdd/rolesToRemove - // const rolesToAdd = newRoles.filter(r => !currentRoles.includes(r)); - // const rolesToRemove = currentRoles.filter(r => !newRoles.includes(r)); - // Loop and call authClient.addRole / authClient.removeRole - - // ** FALLBACK: Direct DB Update (Less Ideal) ** - // Construct the new comma-separated string - newRoleString = newRoles.sort().join(","); // Sort for consistency - } - - // Prepare data for update - const updateData: { role?: string; department?: Departments } = - {}; - if (rolesChanged) { - updateData.role = newRoleString ?? ""; // Use new string or empty string if all roles removed - } - if (departmentChanged) { - updateData.department = department; - } - - // Perform the update if there's data to change - if (Object.keys(updateData).length > 0) { - await db.member.update({ - where: { id: memberId }, - data: updateData, - }); - } - - revalidatePath(`/${orgId}/settings/users`); - revalidateTag(`user_${requestingUserId}`); - - return { - success: true, - data: { updated: true }, - }; - } catch (error) { - console.error("Error updating member role(s):", error); - return { - success: false, - error: "Failed to update member role(s)", - }; - } - }, - ); + .metadata({ + name: "update-member-role", + track: { + event: "update_member_role", + channel: "organization", + }, + }) + .schema(updateMemberRoleSchema) + .action( + async ({ + parsedInput, + ctx, + }): Promise> => { + if (!ctx.session.activeOrganizationId) { + return { + success: false, + error: "User does not have an organization", + }; + } + + const { memberId, roles: newRoles, department } = parsedInput; + const orgId = ctx.session.activeOrganizationId; + const requestingUserId = ctx.user.id; + + try { + // Permission check: User needs to be admin or owner + const currentUserMember = await db.member.findFirst({ + where: { organizationId: orgId, userId: requestingUserId }, + }); + // Check if current user's roles string includes admin or owner + const currentUserRoles = parseRolesString(currentUserMember?.role); + const isAdminOrOwner = + currentUserRoles.includes(Role.admin) || + currentUserRoles.includes(Role.owner); + + if (!isAdminOrOwner) { + return { + success: false, + error: "You don't have permission to update member roles", + }; + } + + // Get target member + const targetMember = await db.member.findFirst({ + where: { id: memberId, organizationId: orgId }, + }); + + if (!targetMember) { + return { + success: false, + error: "Member not found in this organization", + }; + } + + // Parse the target member's current roles from the string + const currentRoles = parseRolesString(targetMember.role); + + // Prevent changing owner's roles + if (currentRoles.includes(Role.owner)) { + return { + success: false, + error: "Cannot change roles for the organization owner.", + }; + } + + // Check if roles or department actually changed + const rolesChanged = !arraysHaveSameElements(currentRoles, newRoles); + const departmentChanged = + department && targetMember.department !== department; + + if (!rolesChanged && !departmentChanged) { + return { + success: true, + data: { updated: false }, + }; // No changes needed + } + + // --- Role Update Logic --- + let newRoleString = targetMember.role; // Start with existing string if only dept changes + + if (rolesChanged) { + console.log( + `Updating roles for member ${targetMember.userId} from ${currentRoles.join(",")} to ${newRoles.join(",")}`, + ); + + // ** PREFERRED: Use authClient methods if available ** + // Example: Calculate rolesToAdd/rolesToRemove + // const rolesToAdd = newRoles.filter(r => !currentRoles.includes(r)); + // const rolesToRemove = currentRoles.filter(r => !newRoles.includes(r)); + // Loop and call authClient.addRole / authClient.removeRole + + // ** FALLBACK: Direct DB Update (Less Ideal) ** + // Construct the new comma-separated string + newRoleString = newRoles.sort().join(","); // Sort for consistency + } + + // Prepare data for update + const updateData: { role?: string; department?: Departments } = {}; + if (rolesChanged) { + updateData.role = newRoleString ?? ""; // Use new string or empty string if all roles removed + } + if (departmentChanged) { + updateData.department = department; + } + + // Perform the update if there's data to change + if (Object.keys(updateData).length > 0) { + await db.member.update({ + where: { id: memberId }, + data: updateData, + }); + } + + revalidatePath(`/${orgId}/settings/users`); + revalidateTag(`user_${requestingUserId}`); + + return { + success: true, + data: { updated: true }, + }; + } catch (error) { + console.error("Error updating member role(s):", error); + return { + success: false, + error: "Failed to update member role(s)", + }; + } + }, + ); 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 2308dbffb5..0aace0e931 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 @@ -13,21 +13,21 @@ import { bulkInviteMembers } from "../actions/bulkInviteMembers"; import type { ActionResponse } from "@/actions/types"; import { Button } from "@comp/ui/button"; import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, } from "@comp/ui/dialog"; import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, } from "@comp/ui/form"; import { Input } from "@comp/ui/input"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@comp/ui/tabs"; @@ -37,625 +37,537 @@ import { addEmployeeWithoutInvite } from "../actions/addEmployeeWithoutInvite"; // --- Constants for Roles --- const selectableRoles = [ - "admin", - "auditor", - "employee", + "admin", + "auditor", + "employee", ] as const satisfies Readonly; type InviteRole = (typeof selectableRoles)[number]; const DEFAULT_ROLES: InviteRole[] = []; // --- Schemas --- const manualInviteSchema = z.object({ - email: z.string().email({ message: "Invalid email address." }), - roles: z - .array(z.enum(selectableRoles)) - .min(1, { message: "Please select at least one role." }), + email: z.string().email({ message: "Invalid email address." }), + roles: z + .array(z.enum(selectableRoles)) + .min(1, { message: "Please select at least one role." }), }); // Define base schemas for each mode const manualModeSchema = z.object({ - mode: z.literal("manual"), - manualInvites: z - .array(manualInviteSchema) - .min(1, { message: "Please add at least one invite." }), - csvFile: z.any().optional(), // Optional here, validated by union + mode: z.literal("manual"), + manualInvites: z + .array(manualInviteSchema) + .min(1, { message: "Please add at least one invite." }), + csvFile: z.any().optional(), // Optional here, validated by union }); const csvModeSchema = z.object({ - mode: z.literal("csv"), - manualInvites: z.array(manualInviteSchema).optional(), // Optional here - csvFile: z - .any() - .refine((val) => val instanceof FileList && val.length === 1, { - message: "Please select a single CSV file.", - }), + mode: z.literal("csv"), + manualInvites: z.array(manualInviteSchema).optional(), // Optional here + csvFile: z + .any() + .refine((val) => val instanceof FileList && val.length === 1, { + message: "Please select a single CSV file.", + }), }); // Combine using discriminatedUnion const formSchema = z.discriminatedUnion("mode", [ - manualModeSchema, - csvModeSchema, + manualModeSchema, + csvModeSchema, ]); type FormData = z.infer; interface InviteMembersModalProps { - open: boolean; - onOpenChange: (open: boolean) => void; - organizationId: string; + open: boolean; + onOpenChange: (open: boolean) => void; + organizationId: string; } interface BulkInviteResultData { - successfulInvites: number; - failedItems: { - input: string | { email: string; role: InviteRole | InviteRole[] }; - error: string; - }[]; + successfulInvites: number; + failedItems: { + input: string | { email: string; role: InviteRole | InviteRole[] }; + error: string; + }[]; } export function InviteMembersModal({ - open, - onOpenChange, - organizationId, + open, + onOpenChange, + organizationId, }: InviteMembersModalProps) { - const router = useRouter(); - const [mode, setMode] = useState<"manual" | "csv">("manual"); - const [isLoading, setIsLoading] = useState(false); - const [csvFileName, setCsvFileName] = useState(null); - const [lastResult, setLastResult] = - useState | null>(null); - - const form = useForm({ - resolver: zodResolver(formSchema), - defaultValues: { - mode: "manual", - manualInvites: [ - { - email: "", - roles: DEFAULT_ROLES, - }, - ], - csvFile: undefined, - }, - mode: "onChange", - }); - - // Log form errors on change - useEffect(() => { - if (Object.keys(form.formState.errors).length > 0) { - console.error("Form Validation Errors:", form.formState.errors); - } - }, [form.formState.errors]); - - const { fields, append, remove } = useFieldArray({ - control: form.control, - name: "manualInvites", - }); - - async function onSubmit(values: FormData) { - console.log("onSubmit triggered", { values }); - setIsLoading(true); - - try { - if (values.mode === "manual") { - console.log("Processing manual mode"); - if ( - !values.manualInvites || - values.manualInvites.length === 0 - ) { - console.error("Manual mode validation failed: No invites."); - toast.error("Please add at least one member to invite."); - setIsLoading(false); - return; - } - - const invalidInvites = values.manualInvites.filter( - (invite) => !invite.roles || invite.roles.length === 0, - ); - if (invalidInvites.length > 0) { - console.error( - `Manual mode validation failed: No roles selected for: ${invalidInvites.map((i) => i.email || "invite").join(", ")}`, - ); - toast.error( - `Please select at least one role for: ${invalidInvites.map((i) => i.email || "invite").join(", ")}`, - ); - setIsLoading(false); - return; - } - - // Process invitations client-side using authClient - let successCount = 0; - const failedInvites: { email: string; error: string }[] = []; - - // Process each invitation sequentially - for (const invite of values.manualInvites) { - const isEmployeeOnly = - invite.roles.length === 1 && - invite.roles[0] === "employee"; - try { - if (isEmployeeOnly) { - await addEmployeeWithoutInvite({ - organizationId, - email: invite.email, - }); - } else { - // Use authClient to send the invitation - await authClient.organization.inviteMember({ - email: invite.email, - role: - invite.roles.length === 1 - ? invite.roles[0] - : invite.roles, - }); - } - successCount++; - } catch (error) { - console.error( - `Failed to invite ${invite.email}:`, - error, - ); - failedInvites.push({ - email: invite.email, - error: - error instanceof Error - ? error.message - : "Unknown error", - }); - } - } - - // Handle results - if (successCount > 0) { - toast.success( - `Successfully invited ${successCount} member(s).`, - ); - - // Revalidate the page to refresh the member list - router.refresh(); - - if (failedInvites.length === 0) { - form.reset(); - onOpenChange(false); - } - } - - if (failedInvites.length > 0) { - toast.error( - `Failed to invite ${failedInvites.length} member(s): ${failedInvites.map((f) => f.email).join(", ")}`, - ); - } - } else if (values.mode === "csv") { - // Handle CSV file uploads - console.log("Processing CSV mode"); - - // Validate file exists and is valid - if ( - !values.csvFile || - !(values.csvFile instanceof FileList) || - values.csvFile.length !== 1 - ) { - console.error( - "CSV mode validation failed: No valid file selected.", - ); - form.setError("csvFile", { - message: "A valid CSV file is required.", - }); - setIsLoading(false); - return; - } - - const file = values.csvFile[0]; - if (file.type !== "text/csv" && !file.name.endsWith(".csv")) { - console.error( - "CSV mode validation failed: Incorrect file type.", - { type: file.type }, - ); - form.setError("csvFile", { - message: "File must be a CSV.", - }); - setIsLoading(false); - return; - } - - if (file.size > 5 * 1024 * 1024) { - console.error( - "CSV mode validation failed: File too large.", - { size: file.size }, - ); - form.setError("csvFile", { - message: "File size must be less than 5MB.", - }); - setIsLoading(false); - return; - } - - try { - // Parse CSV file - const text = await file.text(); - const lines = text.split("\n"); - - // Skip header row, process each line - const header = lines[0].toLowerCase(); - if (!header.includes("email") || !header.includes("role")) { - toast.error( - "Invalid CSV format. The first row must include 'email' and 'role' columns.", - ); - setIsLoading(false); - return; - } - - // Parse header to find column indexes - const headers = header.split(",").map((h) => h.trim()); - const emailIndex = headers.findIndex((h) => h === "email"); - const roleIndex = headers.findIndex((h) => h === "role"); - - if (emailIndex === -1 || roleIndex === -1) { - toast.error( - "CSV must contain 'email' and 'role' columns.", - ); - setIsLoading(false); - return; - } - - // Process rows - const dataRows = lines - .slice(1) - .filter((line) => line.trim() !== ""); - - if (dataRows.length === 0) { - toast.error("CSV file does not contain any data rows."); - setIsLoading(false); - return; - } - - // Track results - let successCount = 0; - const failedInvites: { email: string; error: string }[] = - []; - - // Process each row - for (const row of dataRows) { - const columns = row.split(",").map((col) => col.trim()); - if (columns.length <= Math.max(emailIndex, roleIndex)) { - failedInvites.push({ - email: columns[emailIndex] || "Invalid row", - error: "Invalid CSV row format", - }); - continue; - } - - const email = columns[emailIndex]; - const roleValue = columns[roleIndex]; - - // Validate email - if ( - !email || - !z.string().email().safeParse(email).success - ) { - failedInvites.push({ - email: email || "Invalid email", - error: "Invalid email format", - }); - continue; - } - - // Validate role(s) - const roles = roleValue - .split(",") - .map((r) => r.trim()) as Role[]; - const validRoles = roles.filter((role) => - selectableRoles.includes(role as any), - ); - - if (validRoles.length === 0) { - failedInvites.push({ - email, - error: `Invalid role(s): ${roleValue}. Must be one of: ${selectableRoles.join(", ")}`, - }); - continue; - } - - // Attempt to invite - try { - await authClient.organization.inviteMember({ - email, - role: - validRoles.length === 1 - ? validRoles[0] - : validRoles, - }); - successCount++; - } catch (error) { - console.error(`Failed to invite ${email}:`, error); - failedInvites.push({ - email, - error: - error instanceof Error - ? error.message - : "Unknown error", - }); - } - } - - // Handle results - if (successCount > 0) { - toast.success( - `Successfully invited ${successCount} member(s).`, - ); - - // Revalidate the page to refresh the member list - router.refresh(); - - if (failedInvites.length === 0) { - form.reset(); - onOpenChange(false); - } - } - - if (failedInvites.length > 0) { - toast.error( - `Failed to invite ${failedInvites.length} member(s): ${failedInvites.map((f) => f.email).join(", ")}`, - ); - } - } catch (csvError) { - console.error("Error parsing CSV:", csvError); - toast.error( - "Failed to parse CSV file. Please check the format.", - ); - } - } - } catch (error) { - console.error("Error processing invitations:", error); - toast.error( - "An unexpected error occurred while processing invitations.", - ); - } finally { - setIsLoading(false); - } - } - - const handleModeChange = (newMode: string) => { - if (newMode === "manual" || newMode === "csv") { - setMode(newMode); - form.setValue("mode", newMode, { shouldValidate: true }); - - if (newMode === "manual") { - if (fields.length === 0) { - append({ email: "", roles: DEFAULT_ROLES }); - } - form.setValue("csvFile", undefined); - setCsvFileName(null); - } else if (newMode === "csv") { - form.setValue("manualInvites", undefined); - } - - form.clearErrors(); - } - }; - - const csvTemplate = - "email,role\nexample@domain.com,employee\nuser2@example.com,admin"; - const csvTemplateDataUri = `data:text/csv;charset=utf-8,${encodeURIComponent(csvTemplate)}`; - - return ( - - { - e.preventDefault(); - }} - > - - {"Add User"} - - {"Add an employee to your organization."} - - - -
- - - - Manual - CSV - - - - {fields.map((item, index) => ( -
- ( - - {index === 0 && ( - - {"Email"} - - )} - - - - - - )} - /> - ( - - {index === 0 && ( - - {"Role"} - - )} - - - {error?.message} - - - )} - /> - -
- ))} - - - {"Add an employee to your organization."} - -
- - - ( - - - {"CSV File"} - -
- - - {csvFileName || - "No file chosen"} - -
- - { - const fileList = - event.target.files; - onChange(fileList); - setCsvFileName( - fileList?.[0] - ?.name || null, - ); - }} - className="sr-only" - /> - - - {"Upload a CSV file with columns for 'email' and 'role'."} - - - {"Download CSV template"} - - -
- )} - /> -
-
- - - - - -
- -
-
- ); + const router = useRouter(); + const [mode, setMode] = useState<"manual" | "csv">("manual"); + const [isLoading, setIsLoading] = useState(false); + const [csvFileName, setCsvFileName] = useState(null); + const [lastResult, setLastResult] = + useState | null>(null); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + mode: "manual", + manualInvites: [ + { + email: "", + roles: DEFAULT_ROLES, + }, + ], + csvFile: undefined, + }, + mode: "onChange", + }); + + // Log form errors on change + useEffect(() => { + if (Object.keys(form.formState.errors).length > 0) { + console.error("Form Validation Errors:", form.formState.errors); + } + }, [form.formState.errors]); + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "manualInvites", + }); + + async function onSubmit(values: FormData) { + console.log("onSubmit triggered", { values }); + setIsLoading(true); + + try { + if (values.mode === "manual") { + console.log("Processing manual mode"); + if (!values.manualInvites || values.manualInvites.length === 0) { + console.error("Manual mode validation failed: No invites."); + toast.error("Please add at least one member to invite."); + setIsLoading(false); + return; + } + + const invalidInvites = values.manualInvites.filter( + (invite) => !invite.roles || invite.roles.length === 0, + ); + if (invalidInvites.length > 0) { + console.error( + `Manual mode validation failed: No roles selected for: ${invalidInvites.map((i) => i.email || "invite").join(", ")}`, + ); + toast.error( + `Please select at least one role for: ${invalidInvites.map((i) => i.email || "invite").join(", ")}`, + ); + setIsLoading(false); + return; + } + + // Process invitations client-side using authClient + let successCount = 0; + const failedInvites: { email: string; error: string }[] = []; + + // Process each invitation sequentially + for (const invite of values.manualInvites) { + const isEmployeeOnly = + invite.roles.length === 1 && invite.roles[0] === "employee"; + try { + if (isEmployeeOnly) { + await addEmployeeWithoutInvite({ + organizationId, + email: invite.email, + }); + } else { + // Use authClient to send the invitation + await authClient.organization.inviteMember({ + email: invite.email, + role: + invite.roles.length === 1 ? invite.roles[0] : invite.roles, + }); + } + successCount++; + } catch (error) { + console.error(`Failed to invite ${invite.email}:`, error); + failedInvites.push({ + email: invite.email, + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + + // Handle results + if (successCount > 0) { + toast.success(`Successfully invited ${successCount} member(s).`); + + // Revalidate the page to refresh the member list + router.refresh(); + + if (failedInvites.length === 0) { + form.reset(); + onOpenChange(false); + } + } + + if (failedInvites.length > 0) { + toast.error( + `Failed to invite ${failedInvites.length} member(s): ${failedInvites.map((f) => f.email).join(", ")}`, + ); + } + } else if (values.mode === "csv") { + // Handle CSV file uploads + console.log("Processing CSV mode"); + + // Validate file exists and is valid + if ( + !values.csvFile || + !(values.csvFile instanceof FileList) || + values.csvFile.length !== 1 + ) { + console.error("CSV mode validation failed: No valid file selected."); + form.setError("csvFile", { + message: "A valid CSV file is required.", + }); + setIsLoading(false); + return; + } + + const file = values.csvFile[0]; + if (file.type !== "text/csv" && !file.name.endsWith(".csv")) { + console.error("CSV mode validation failed: Incorrect file type.", { + type: file.type, + }); + form.setError("csvFile", { + message: "File must be a CSV.", + }); + setIsLoading(false); + return; + } + + if (file.size > 5 * 1024 * 1024) { + console.error("CSV mode validation failed: File too large.", { + size: file.size, + }); + form.setError("csvFile", { + message: "File size must be less than 5MB.", + }); + setIsLoading(false); + return; + } + + try { + // Parse CSV file + const text = await file.text(); + const lines = text.split("\n"); + + // Skip header row, process each line + const header = lines[0].toLowerCase(); + if (!header.includes("email") || !header.includes("role")) { + toast.error( + "Invalid CSV format. The first row must include 'email' and 'role' columns.", + ); + setIsLoading(false); + return; + } + + // Parse header to find column indexes + const headers = header.split(",").map((h) => h.trim()); + const emailIndex = headers.findIndex((h) => h === "email"); + const roleIndex = headers.findIndex((h) => h === "role"); + + if (emailIndex === -1 || roleIndex === -1) { + toast.error("CSV must contain 'email' and 'role' columns."); + setIsLoading(false); + return; + } + + // Process rows + const dataRows = lines.slice(1).filter((line) => line.trim() !== ""); + + if (dataRows.length === 0) { + toast.error("CSV file does not contain any data rows."); + setIsLoading(false); + return; + } + + // Track results + let successCount = 0; + const failedInvites: { email: string; error: string }[] = []; + + // Process each row + for (const row of dataRows) { + const columns = row.split(",").map((col) => col.trim()); + if (columns.length <= Math.max(emailIndex, roleIndex)) { + failedInvites.push({ + email: columns[emailIndex] || "Invalid row", + error: "Invalid CSV row format", + }); + continue; + } + + const email = columns[emailIndex]; + const roleValue = columns[roleIndex]; + + // Validate email + if (!email || !z.string().email().safeParse(email).success) { + failedInvites.push({ + email: email || "Invalid email", + error: "Invalid email format", + }); + continue; + } + + // Validate role(s) + const roles = roleValue.split(",").map((r) => r.trim()) as Role[]; + const validRoles = roles.filter((role) => + selectableRoles.includes(role as any), + ); + + if (validRoles.length === 0) { + failedInvites.push({ + email, + error: `Invalid role(s): ${roleValue}. Must be one of: ${selectableRoles.join(", ")}`, + }); + continue; + } + + // Attempt to invite + try { + await authClient.organization.inviteMember({ + email, + role: validRoles.length === 1 ? validRoles[0] : validRoles, + }); + successCount++; + } catch (error) { + console.error(`Failed to invite ${email}:`, error); + failedInvites.push({ + email, + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + + // Handle results + if (successCount > 0) { + toast.success(`Successfully invited ${successCount} member(s).`); + + // Revalidate the page to refresh the member list + router.refresh(); + + if (failedInvites.length === 0) { + form.reset(); + onOpenChange(false); + } + } + + if (failedInvites.length > 0) { + toast.error( + `Failed to invite ${failedInvites.length} member(s): ${failedInvites.map((f) => f.email).join(", ")}`, + ); + } + } catch (csvError) { + console.error("Error parsing CSV:", csvError); + toast.error("Failed to parse CSV file. Please check the format."); + } + } + } catch (error) { + console.error("Error processing invitations:", error); + toast.error("An unexpected error occurred while processing invitations."); + } finally { + setIsLoading(false); + } + } + + const handleModeChange = (newMode: string) => { + if (newMode === "manual" || newMode === "csv") { + setMode(newMode); + form.setValue("mode", newMode, { shouldValidate: true }); + + if (newMode === "manual") { + if (fields.length === 0) { + append({ email: "", roles: DEFAULT_ROLES }); + } + form.setValue("csvFile", undefined); + setCsvFileName(null); + } else if (newMode === "csv") { + form.setValue("manualInvites", undefined); + } + + form.clearErrors(); + } + }; + + const csvTemplate = + "email,role\nexample@domain.com,employee\nuser2@example.com,admin"; + const csvTemplateDataUri = `data:text/csv;charset=utf-8,${encodeURIComponent(csvTemplate)}`; + + return ( + + { + e.preventDefault(); + }} + > + + {"Add User"} + + {"Add an employee to your organization."} + + + +
+ + + + Manual + CSV + + + + {fields.map((item, index) => ( +
+ ( + + {index === 0 && {"Email"}} + + + + + + )} + /> + ( + + {index === 0 && {"Role"}} + + {error?.message} + + )} + /> + +
+ ))} + + + {"Add an employee to your organization."} + +
+ + + ( + + {"CSV File"} +
+ + + {csvFileName || "No file chosen"} + +
+ + { + const fileList = event.target.files; + onChange(fileList); + setCsvFileName(fileList?.[0]?.name || null); + }} + className="sr-only" + /> + + + { + "Upload a CSV file with columns for 'email' and 'role'." + } + +
+ {"Download CSV template"} + + + + )} + /> + + + + + + + + + + + + ); } 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 b815f44349..02e5e6352e 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 @@ -6,33 +6,33 @@ import { useParams } from "next/navigation"; import React, { useState, useRef } from "react"; import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, } from "@comp/ui/alert-dialog"; import { Avatar, AvatarFallback, AvatarImage } from "@comp/ui/avatar"; import { Badge } from "@comp/ui/badge"; import { Button } from "@comp/ui/button"; import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, } from "@comp/ui/dialog"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, } from "@comp/ui/dropdown-menu"; import { Label } from "@comp/ui/label"; import type { Role } from "@prisma/client"; @@ -41,260 +41,260 @@ import { MultiRoleCombobox } from "./MultiRoleCombobox"; import type { MemberWithUser } from "./TeamMembers"; interface MemberRowProps { - member: MemberWithUser; - onRemove: (memberId: string) => void; - onUpdateRole: (memberId: string, roles: Role[]) => void; + member: MemberWithUser; + onRemove: (memberId: string) => void; + onUpdateRole: (memberId: string, roles: Role[]) => void; } // Helper to get initials function getInitials(name?: string | null, email?: string | null): string { - if (name) { - return name - .split(" ") - .map((n) => n[0]) - .join("") - .toUpperCase(); - } - if (email) { - return email.substring(0, 2).toUpperCase(); - } - return "??"; + if (name) { + return name + .split(" ") + .map((n) => n[0]) + .join("") + .toUpperCase(); + } + if (email) { + return email.substring(0, 2).toUpperCase(); + } + return "??"; } export function MemberRow({ member, onRemove, onUpdateRole }: MemberRowProps) { - const params = useParams<{ orgId: string }>(); - const { orgId } = params; + const params = useParams<{ orgId: string }>(); + const { orgId } = params; - const [isRemoveAlertOpen, setIsRemoveAlertOpen] = useState(false); - const [isUpdateRolesOpen, setIsUpdateRolesOpen] = useState(false); - const [dropdownOpen, setDropdownOpen] = useState(false); - const [selectedRoles, setSelectedRoles] = useState( - Array.isArray(member.role) ? member.role : ([member.role] as Role[]), - ); - const [isUpdating, setIsUpdating] = useState(false); - const [isRemoving, setIsRemoving] = useState(false); - const dropdownTriggerRef = useRef(null); - const focusRef = useRef(null); - const currentUserIsOwner = member.role === "owner"; + const [isRemoveAlertOpen, setIsRemoveAlertOpen] = useState(false); + const [isUpdateRolesOpen, setIsUpdateRolesOpen] = useState(false); + const [dropdownOpen, setDropdownOpen] = useState(false); + const [selectedRoles, setSelectedRoles] = useState( + Array.isArray(member.role) ? member.role : ([member.role] as Role[]), + ); + const [isUpdating, setIsUpdating] = useState(false); + const [isRemoving, setIsRemoving] = useState(false); + const dropdownTriggerRef = useRef(null); + const focusRef = useRef(null); + const currentUserIsOwner = member.role === "owner"; - const memberName = member.user.name || member.user.email || "Member"; - const memberEmail = member.user.email || ""; - const memberAvatar = member.user.image; - const memberId = member.id; - const currentRoles = ( - Array.isArray(member.role) - ? member.role - : typeof member.role === "string" && member.role.includes(",") - ? (member.role.split(",") as Role[]) - : [member.role] - ) as Role[]; + const memberName = member.user.name || member.user.email || "Member"; + const memberEmail = member.user.email || ""; + const memberAvatar = member.user.image; + const memberId = member.id; + const currentRoles = ( + Array.isArray(member.role) + ? member.role + : typeof member.role === "string" && member.role.includes(",") + ? (member.role.split(",") as Role[]) + : [member.role] + ) as Role[]; - const isOwner = currentRoles.includes("owner"); - const canEditRoles = true; - const canRemove = !isOwner; + const isOwner = currentRoles.includes("owner"); + const canEditRoles = true; + const canRemove = !isOwner; - const isEmployee = currentRoles.includes("employee"); + const isEmployee = currentRoles.includes("employee"); - const handleDialogItemSelect = () => { - focusRef.current = dropdownTriggerRef.current; - }; + const handleDialogItemSelect = () => { + focusRef.current = dropdownTriggerRef.current; + }; - const handleDialogOpenChange = (open: boolean) => { - setIsUpdateRolesOpen(open); - if (open === false) { - setDropdownOpen(false); - } - }; + const handleDialogOpenChange = (open: boolean) => { + setIsUpdateRolesOpen(open); + if (open === false) { + setDropdownOpen(false); + } + }; - const handleUpdateRolesClick = async () => { - console.log("handleUpdateRolesClick"); - let rolesToUpdate = selectedRoles; - if (isOwner && !rolesToUpdate.includes("owner")) { - rolesToUpdate = [...rolesToUpdate, "owner"]; - } + const handleUpdateRolesClick = async () => { + console.log("handleUpdateRolesClick"); + let rolesToUpdate = selectedRoles; + if (isOwner && !rolesToUpdate.includes("owner")) { + rolesToUpdate = [...rolesToUpdate, "owner"]; + } - // Don't update if no roles are selected - if (rolesToUpdate.length === 0) { - return; - } + // Don't update if no roles are selected + if (rolesToUpdate.length === 0) { + return; + } - setIsUpdating(true); - await onUpdateRole(memberId, rolesToUpdate); - setIsUpdating(false); - setIsUpdateRolesOpen(false); // Close dialog after update - }; + setIsUpdating(true); + await onUpdateRole(memberId, rolesToUpdate); + setIsUpdating(false); + setIsUpdateRolesOpen(false); // Close dialog after update + }; - const handleRemoveClick = async () => { - if (!canRemove) return; - setIsRemoving(true); - await onRemove(memberId); - setIsRemoving(false); - setIsRemoveAlertOpen(false); - }; + const handleRemoveClick = async () => { + if (!canRemove) return; + setIsRemoving(true); + await onRemove(memberId); + setIsRemoving(false); + setIsRemoveAlertOpen(false); + }; - return ( - <> -
-
- - - - {getInitials(member.user.name, member.user.email)} - - -
-
- {memberName} - {isEmployee && ( - - ({"View Profile"}) - - )} -
-
{memberEmail}
-
-
-
-
- {currentRoles.map((role) => ( - - {(() => { - switch (role) { - case "owner": - return "Owner"; - case "admin": - return "Admin"; - case "auditor": - return "Auditor"; - case "employee": - return "Employee"; - default: - return "???"; - } - })()} - - ))} -
+ return ( + <> +
+
+ + + + {getInitials(member.user.name, member.user.email)} + + +
+
+ {memberName} + {isEmployee && ( + + ({"View Profile"}) + + )} +
+
{memberEmail}
+
+
+
+
+ {currentRoles.map((role) => ( + + {(() => { + switch (role) { + case "owner": + return "Owner"; + case "admin": + return "Admin"; + case "auditor": + return "Auditor"; + case "employee": + return "Employee"; + default: + return "???"; + } + })()} + + ))} +
- - - - - - -
-
+ + + + + + +
+
- - - - {"Remove Team Member"} - - {"Are you sure you want to remove"} {memberName}?{" "} - {"They will no longer have access to this organization."} - - - - {"Cancel"} - - {"Remove"} - - - - - - ); + + + + {"Remove Team Member"} + + {"Are you sure you want to remove"} {memberName}?{" "} + {"They will no longer have access to this organization."} + + + + {"Cancel"} + + {"Remove"} + + + + + + ); } diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleCombobox.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleCombobox.tsx index 1829d82027..26ce2bf06d 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleCombobox.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleCombobox.tsx @@ -9,147 +9,147 @@ import { MultiRoleComboboxTrigger } from "./MultiRoleComboboxTrigger"; // Define the selectable roles explicitly (exclude owner) const selectableRoles: { - value: Role; - labelKey: string; - descriptionKey: string; + value: Role; + labelKey: string; + descriptionKey: string; }[] = [ - { - value: "owner", - labelKey: "people.roles.owner", - descriptionKey: "people.roles.owner_description", - }, - { - value: "admin", - labelKey: "people.roles.admin", - descriptionKey: "people.roles.admin_description", - }, - { - value: "employee", - labelKey: "people.roles.employee", - descriptionKey: "people.roles.employee_description", - }, - { - value: "auditor", - labelKey: "people.roles.auditor", - descriptionKey: "people.roles.auditor_description", - }, + { + value: "owner", + labelKey: "people.roles.owner", + descriptionKey: "people.roles.owner_description", + }, + { + value: "admin", + labelKey: "people.roles.admin", + descriptionKey: "people.roles.admin_description", + }, + { + value: "employee", + labelKey: "people.roles.employee", + descriptionKey: "people.roles.employee_description", + }, + { + value: "auditor", + labelKey: "people.roles.auditor", + descriptionKey: "people.roles.auditor_description", + }, ]; interface MultiRoleComboboxProps { - selectedRoles: Role[]; - onSelectedRolesChange: (roles: Role[]) => void; - placeholder?: string; - disabled?: boolean; - lockedRoles?: Role[]; // Roles that cannot be deselected + selectedRoles: Role[]; + onSelectedRolesChange: (roles: Role[]) => void; + placeholder?: string; + disabled?: boolean; + lockedRoles?: Role[]; // Roles that cannot be deselected } export function MultiRoleCombobox({ - selectedRoles: inputSelectedRoles, - onSelectedRolesChange, - placeholder, - disabled = false, - lockedRoles = [], + selectedRoles: inputSelectedRoles, + onSelectedRolesChange, + placeholder, + disabled = false, + lockedRoles = [], }: MultiRoleComboboxProps) { - const [open, setOpen] = React.useState(false); - const [searchTerm, setSearchTerm] = React.useState(""); + const [open, setOpen] = React.useState(false); + const [searchTerm, setSearchTerm] = React.useState(""); - // Process selected roles to handle comma-separated values - const selectedRoles = React.useMemo(() => { - return inputSelectedRoles.flatMap((role) => - typeof role === "string" && role.includes(",") - ? (role.split(",") as Role[]) - : [role], - ); - }, [inputSelectedRoles]); + // Process selected roles to handle comma-separated values + const selectedRoles = React.useMemo(() => { + return inputSelectedRoles.flatMap((role) => + typeof role === "string" && role.includes(",") + ? (role.split(",") as Role[]) + : [role], + ); + }, [inputSelectedRoles]); - const isOwner = selectedRoles.includes("owner"); + const isOwner = selectedRoles.includes("owner"); - // Filter out owner role for non-owners - const availableRoles = React.useMemo(() => { - return selectableRoles.filter((role) => role.value !== "owner" || isOwner); - }, [isOwner]); + // Filter out owner role for non-owners + const availableRoles = React.useMemo(() => { + return selectableRoles.filter((role) => role.value !== "owner" || isOwner); + }, [isOwner]); - const handleSelect = (roleValue: Role) => { - // Never allow owner role to be changed - if (roleValue === "owner") { - return; - } + const handleSelect = (roleValue: Role) => { + // Never allow owner role to be changed + if (roleValue === "owner") { + return; + } - // If the role is locked, don't allow deselection - if (lockedRoles.includes(roleValue) && selectedRoles.includes(roleValue)) { - return; // Don't allow deselection of locked roles - } + // If the role is locked, don't allow deselection + if (lockedRoles.includes(roleValue) && selectedRoles.includes(roleValue)) { + return; // Don't allow deselection of locked roles + } - // Allow removal of any non-locked role, even if it's the last one - const newSelectedRoles = selectedRoles.includes(roleValue) - ? selectedRoles.filter((r) => r !== roleValue) - : [...selectedRoles, roleValue]; - onSelectedRolesChange(newSelectedRoles); - }; + // Allow removal of any non-locked role, even if it's the last one + const newSelectedRoles = selectedRoles.includes(roleValue) + ? selectedRoles.filter((r) => r !== roleValue) + : [...selectedRoles, roleValue]; + onSelectedRolesChange(newSelectedRoles); + }; - const getRoleLabel = (roleValue: Role) => { - switch (roleValue) { - case "owner": - return "Owner"; - case "admin": - return "Admin"; - case "auditor": - return "Auditor"; - case "employee": - return "Employee"; - default: - return roleValue; - } - }; + const getRoleLabel = (roleValue: Role) => { + switch (roleValue) { + case "owner": + return "Owner"; + case "admin": + return "Admin"; + case "auditor": + return "Auditor"; + case "employee": + return "Employee"; + default: + return roleValue; + } + }; - const triggerText = - selectedRoles.length > 0 - ? `${selectedRoles.length} selected` - : placeholder || "Select role(s)"; + const triggerText = + selectedRoles.length > 0 + ? `${selectedRoles.length} selected` + : placeholder || "Select role(s)"; - const filteredRoles = availableRoles.filter((role) => { - const label = (() => { - switch (role.value) { - case "admin": - return "Admin"; - case "auditor": - return "Auditor"; - case "employee": - return "Employee"; - case "owner": - return "Owner"; - default: - return role.value; - } - })(); - return label.toLowerCase().includes(searchTerm.toLowerCase()); - }); + const filteredRoles = availableRoles.filter((role) => { + const label = (() => { + switch (role.value) { + case "admin": + return "Admin"; + case "auditor": + return "Auditor"; + case "employee": + return "Employee"; + case "owner": + return "Owner"; + default: + return role.value; + } + })(); + return label.toLowerCase().includes(searchTerm.toLowerCase()); + }); - return ( - <> - setOpen(true)} - ariaExpanded={open} - /> - - - setOpen(false)} - /> - - - - ); + return ( + <> + setOpen(true)} + ariaExpanded={open} + /> + + + setOpen(false)} + /> + + + + ); } diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleComboboxContent.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleComboboxContent.tsx index b219a28127..7c0aba973f 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleComboboxContent.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleComboboxContent.tsx @@ -4,123 +4,123 @@ import * as React from "react"; import { Check } from "lucide-react"; import type { Role } from "@prisma/client"; // Assuming Role is from prisma import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, } from "@comp/ui/command"; import { cn } from "@comp/ui/cn"; interface MultiRoleComboboxContentProps { - searchTerm: string; - setSearchTerm: (value: string) => void; - filteredRoles: Array<{ value: Role }>; // Role objects, labels derived via t() - handleSelect: (roleValue: Role) => void; - lockedRoles: Role[]; - selectedRoles: Role[]; - onCloseDialog: () => void; + searchTerm: string; + setSearchTerm: (value: string) => void; + filteredRoles: Array<{ value: Role }>; // Role objects, labels derived via t() + handleSelect: (roleValue: Role) => void; + lockedRoles: Role[]; + selectedRoles: Role[]; + onCloseDialog: () => void; } export function MultiRoleComboboxContent({ - searchTerm, - setSearchTerm, - filteredRoles, - handleSelect, - lockedRoles, - selectedRoles, - onCloseDialog, + searchTerm, + setSearchTerm, + filteredRoles, + handleSelect, + lockedRoles, + selectedRoles, + onCloseDialog, }: MultiRoleComboboxContentProps) { - const getRoleDisplayLabel = (roleValue: Role) => { - switch (roleValue) { - case "owner": - return "Owner"; - case "admin": - return "Admin"; - case "auditor": - return "Auditor"; - case "employee": - return "Employee"; - default: - return roleValue; - } - }; + const getRoleDisplayLabel = (roleValue: Role) => { + switch (roleValue) { + case "owner": + return "Owner"; + case "admin": + return "Admin"; + case "auditor": + return "Auditor"; + case "employee": + return "Employee"; + default: + return roleValue; + } + }; - const getRoleDescription = (roleValue: Role) => { - switch (roleValue) { - case "owner": - return "Can manage users, policies, tasks, and settings, and delete organization."; - case "admin": - return "Can manage users, policies, tasks, and settings."; - case "auditor": - return "Read-only access for compliance checks."; - case "employee": - return "Can sign policies and complete training."; - default: - return ""; - } - }; + const getRoleDescription = (roleValue: Role) => { + switch (roleValue) { + case "owner": + return "Can manage users, policies, tasks, and settings, and delete organization."; + case "admin": + return "Can manage users, policies, tasks, and settings."; + case "auditor": + return "Read-only access for compliance checks."; + case "employee": + return "Can sign policies and complete training."; + default: + return ""; + } + }; - return ( - - - - {"No results found"} - - {filteredRoles.map((role) => ( - e.preventDefault()} - onClick={(e) => e.stopPropagation()} - onSelect={() => { - handleSelect(role.value); - onCloseDialog(); - }} - disabled={ - role.value === "owner" || // Always disable the owner role - (lockedRoles.includes(role.value) && - selectedRoles.includes(role.value)) // Disable any locked roles - } - className={cn( - "flex flex-col items-start py-2 cursor-pointer", - lockedRoles.includes(role.value) && - selectedRoles.includes(role.value) && - "bg-muted/50 text-muted-foreground", - )} - > -
- - - {getRoleDisplayLabel(role.value)} - - {lockedRoles.includes(role.value) && - selectedRoles.includes(role.value) && ( - - (Locked) - - )} -
-
- {getRoleDescription(role.value)} -
-
- ))} -
-
-
- ); + return ( + + + + {"No results found"} + + {filteredRoles.map((role) => ( + e.preventDefault()} + onClick={(e) => e.stopPropagation()} + onSelect={() => { + handleSelect(role.value); + onCloseDialog(); + }} + disabled={ + role.value === "owner" || // Always disable the owner role + (lockedRoles.includes(role.value) && + selectedRoles.includes(role.value)) // Disable any locked roles + } + className={cn( + "flex cursor-pointer flex-col items-start py-2", + lockedRoles.includes(role.value) && + selectedRoles.includes(role.value) && + "bg-muted/50 text-muted-foreground", + )} + > +
+ + + {getRoleDisplayLabel(role.value)} + + {lockedRoles.includes(role.value) && + selectedRoles.includes(role.value) && ( + + (Locked) + + )} +
+
+ {getRoleDescription(role.value)} +
+
+ ))} +
+
+
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleComboboxTrigger.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleComboboxTrigger.tsx index 8bcab06c62..1405cb9c6f 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleComboboxTrigger.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/MultiRoleComboboxTrigger.tsx @@ -6,84 +6,80 @@ import type { Role } from "@prisma/client"; // Assuming Role is from prisma import { Button } from "@comp/ui/button"; import { Badge } from "@comp/ui/badge"; import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, } from "@comp/ui/tooltip"; import { cn } from "@comp/ui/cn"; interface MultiRoleComboboxTriggerProps { - selectedRoles: Role[]; - lockedRoles: Role[]; - triggerText: string; - disabled?: boolean; - handleSelect: (role: Role) => void; // For badge click to deselect/select - getRoleLabel: (role: Role) => string; - onClick?: () => void; - ariaExpanded?: boolean; + selectedRoles: Role[]; + lockedRoles: Role[]; + triggerText: string; + disabled?: boolean; + handleSelect: (role: Role) => void; // For badge click to deselect/select + getRoleLabel: (role: Role) => string; + onClick?: () => void; + ariaExpanded?: boolean; } export function MultiRoleComboboxTrigger({ - selectedRoles, - lockedRoles, - triggerText, - disabled, - handleSelect, - getRoleLabel, - onClick, - ariaExpanded, + selectedRoles, + lockedRoles, + triggerText, + disabled, + handleSelect, + getRoleLabel, + onClick, + ariaExpanded, }: MultiRoleComboboxTriggerProps) { - return ( - - ); + return ( + + ); } diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/PendingInvitationRow.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/PendingInvitationRow.tsx index 9c120fb5af..5784a8fce4 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/PendingInvitationRow.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/PendingInvitationRow.tsx @@ -4,20 +4,20 @@ import { Avatar, AvatarFallback } from "@comp/ui/avatar"; import { Badge } from "@comp/ui/badge"; import { Button } from "@comp/ui/button"; import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, - DialogClose, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogClose, } from "@comp/ui/dialog"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, } from "@comp/ui/dropdown-menu"; import type { Invitation } from "@prisma/client"; import { formatDistanceToNowStrict } from "date-fns"; @@ -25,172 +25,172 @@ import { Clock, MoreHorizontal, Trash2 } from "lucide-react"; import { useMemo, useRef, useState, useEffect } from "react"; interface PendingInvitationRowProps { - invitation: Invitation & { - role: string; - createdAt?: Date; - }; - onCancel: (invitationId: string) => Promise; + invitation: Invitation & { + role: string; + createdAt?: Date; + }; + onCancel: (invitationId: string) => Promise; } export function PendingInvitationRow({ - invitation, - onCancel, + invitation, + onCancel, }: PendingInvitationRowProps) { - const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false); - const [isCancelling, setIsCancelling] = useState(false); - const [dropdownOpen, setDropdownOpen] = useState(false); - const dropdownTriggerRef = useRef(null); - const focusRef = useRef(null); + const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false); + const [isCancelling, setIsCancelling] = useState(false); + const [dropdownOpen, setDropdownOpen] = useState(false); + const dropdownTriggerRef = useRef(null); + const focusRef = useRef(null); - const handleDialogItemSelect = () => { - focusRef.current = dropdownTriggerRef.current; - }; + const handleDialogItemSelect = () => { + focusRef.current = dropdownTriggerRef.current; + }; - const [pendingRemove, setPendingRemove] = useState(false); + const [pendingRemove, setPendingRemove] = useState(false); - const handleDialogOpenChange = (open: boolean) => { - setIsCancelDialogOpen(open); - if (!open) { - setDropdownOpen(false); - setTimeout(() => { - dropdownTriggerRef.current?.focus(); - }, 0); - } - }; + const handleDialogOpenChange = (open: boolean) => { + setIsCancelDialogOpen(open); + if (!open) { + setDropdownOpen(false); + setTimeout(() => { + dropdownTriggerRef.current?.focus(); + }, 0); + } + }; - const handleCancelClick = () => { - setPendingRemove(true); - setIsCancelDialogOpen(false); - }; + const handleCancelClick = () => { + setPendingRemove(true); + setIsCancelDialogOpen(false); + }; - useEffect(() => { - if (pendingRemove && !isCancelDialogOpen) { - (async () => { - setIsCancelling(true); - await onCancel(invitation.id); - setIsCancelling(false); - setPendingRemove(false); - })(); - } - }, [pendingRemove, isCancelDialogOpen, onCancel, invitation.id]); + useEffect(() => { + if (pendingRemove && !isCancelDialogOpen) { + (async () => { + setIsCancelling(true); + await onCancel(invitation.id); + setIsCancelling(false); + setPendingRemove(false); + })(); + } + }, [pendingRemove, isCancelDialogOpen, onCancel, invitation.id]); - const roleDisplay = useMemo(() => { - return invitation.role; - }, [invitation.role]); + const roleDisplay = useMemo(() => { + return invitation.role; + }, [invitation.role]); - return ( - <> -
-
- - - {invitation.email.charAt(0).toUpperCase()} - - -
-
- {invitation.email} - - - Pending - -
- {/* No secondary email line for invitations */} -
-
-
-
- {(Array.isArray(invitation.role) - ? invitation.role - : typeof invitation.role === "string" && - invitation.role.includes(",") - ? invitation.role.split(",") - : [invitation.role] - ).map((role: string) => ( - - {role.charAt(0).toUpperCase() + role.slice(1)} - - ))} -
- - - - - - -
-
- - ); + return ( + <> +
+
+ + + {invitation.email.charAt(0).toUpperCase()} + + +
+
+ {invitation.email} + + + Pending + +
+ {/* No secondary email line for invitations */} +
+
+
+
+ {(Array.isArray(invitation.role) + ? invitation.role + : typeof invitation.role === "string" && + invitation.role.includes(",") + ? invitation.role.split(",") + : [invitation.role] + ).map((role: string) => ( + + {role.charAt(0).toUpperCase() + role.slice(1)} + + ))} +
+ + + + + + +
+
+ + ); } 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 180475c9be..00540e9263 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 @@ -9,62 +9,62 @@ import { headers } from "next/headers"; import { TeamMembersClient } from "./TeamMembersClient"; export interface MemberWithUser extends Member { - user: User; + user: User; } export interface TeamMembersData { - members: MemberWithUser[]; - pendingInvitations: Invitation[]; + members: MemberWithUser[]; + pendingInvitations: Invitation[]; } export async function TeamMembers() { - const session = await auth.api.getSession({ - headers: await headers(), - }); - const organizationId = session?.session.activeOrganizationId; + const session = await auth.api.getSession({ + headers: await headers(), + }); + const organizationId = session?.session.activeOrganizationId; - let members: MemberWithUser[] = []; - let pendingInvitations: Invitation[] = []; + let members: MemberWithUser[] = []; + let pendingInvitations: Invitation[] = []; - if (organizationId) { - const fetchedMembers = await db.member.findMany({ - where: { - organizationId: organizationId, - }, - include: { - user: true, - }, - orderBy: { - user: { - email: "asc", - }, - }, - }); + if (organizationId) { + const fetchedMembers = await db.member.findMany({ + where: { + organizationId: organizationId, + }, + include: { + user: true, + }, + orderBy: { + user: { + email: "asc", + }, + }, + }); - members = fetchedMembers; + members = fetchedMembers; - pendingInvitations = await db.invitation.findMany({ - where: { - organizationId, - status: "pending", - }, - orderBy: { - email: "asc", - }, - }); - } + pendingInvitations = await db.invitation.findMany({ + where: { + organizationId, + status: "pending", + }, + orderBy: { + email: "asc", + }, + }); + } - const data: TeamMembersData = { - members: members, - pendingInvitations: pendingInvitations, - }; + const data: TeamMembersData = { + members: members, + pendingInvitations: pendingInvitations, + }; - return ( - - ); + return ( + + ); } 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 2aba40a7f6..768183f37e 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 @@ -11,11 +11,11 @@ import { Button } from "@comp/ui/button"; import { Card, CardContent } from "@comp/ui/card"; import { Input } from "@comp/ui/input"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, } from "@comp/ui/select"; import { Separator } from "@comp/ui/separator"; import type { Invitation, Role } from "@prisma/client"; @@ -32,270 +32,270 @@ import { InviteMembersModal } from "./InviteMembersModal"; // Define prop types using typeof for the actions still used interface TeamMembersClientProps { - data: TeamMembersData; - organizationId: string; - removeMemberAction: typeof removeMember; - revokeInvitationAction: typeof revokeInvitation; + data: TeamMembersData; + organizationId: string; + removeMemberAction: typeof removeMember; + revokeInvitationAction: typeof revokeInvitation; } // Define a simplified type for merged list items interface DisplayItem extends Partial, Partial { - type: "member" | "invitation"; - displayName: string; - displayEmail: string; - displayRole: string | string[]; // Simplified role display, could be comma-separated - displayStatus: "active" | "pending"; - displayId: string; // Use member.id or invitation.id - processedRoles: Role[]; + type: "member" | "invitation"; + displayName: string; + displayEmail: string; + displayRole: string | string[]; // Simplified role display, could be comma-separated + displayStatus: "active" | "pending"; + displayId: string; // Use member.id or invitation.id + processedRoles: Role[]; } export function TeamMembersClient({ - data, - organizationId, - removeMemberAction, - revokeInvitationAction, + data, + organizationId, + removeMemberAction, + revokeInvitationAction, }: TeamMembersClientProps) { - const router = useRouter(); - const [searchQuery, setSearchQuery] = useQueryState( - "search", - parseAsString.withDefault(""), - ); - const [roleFilter, setRoleFilter] = useQueryState( - "role", - parseAsString.withDefault("all"), - ); + const router = useRouter(); + const [searchQuery, setSearchQuery] = useQueryState( + "search", + parseAsString.withDefault(""), + ); + const [roleFilter, setRoleFilter] = useQueryState( + "role", + parseAsString.withDefault("all"), + ); - // Add state for the modal - const [isInviteModalOpen, setIsInviteModalOpen] = useState(false); + // Add state for the modal + const [isInviteModalOpen, setIsInviteModalOpen] = useState(false); - // Combine and type members and invitations for filtering/display - const allItems: DisplayItem[] = [ - ...data.members.map((member) => { - // Process the role to handle comma-separated values - const roles = - typeof member.role === "string" && member.role.includes(",") - ? (member.role.split(",") as Role[]) - : Array.isArray(member.role) - ? member.role - : [member.role as Role]; + // Combine and type members and invitations for filtering/display + const allItems: DisplayItem[] = [ + ...data.members.map((member) => { + // Process the role to handle comma-separated values + const roles = + typeof member.role === "string" && member.role.includes(",") + ? (member.role.split(",") as Role[]) + : Array.isArray(member.role) + ? member.role + : [member.role as Role]; - return { - ...member, - type: "member" as const, - displayName: member.user.name || member.user.email || "", - displayEmail: member.user.email || "", - displayRole: member.role, // Keep original for filtering - displayStatus: "active" as const, - displayId: member.id, - // Add processed roles for rendering - processedRoles: roles, - }; - }), - ...data.pendingInvitations.map((invitation) => { - // Process the role to handle comma-separated values - const roles = - typeof invitation.role === "string" && invitation.role.includes(",") - ? (invitation.role.split(",") as Role[]) - : Array.isArray(invitation.role) - ? invitation.role - : [invitation.role as Role]; + return { + ...member, + type: "member" as const, + displayName: member.user.name || member.user.email || "", + displayEmail: member.user.email || "", + displayRole: member.role, // Keep original for filtering + displayStatus: "active" as const, + displayId: member.id, + // Add processed roles for rendering + processedRoles: roles, + }; + }), + ...data.pendingInvitations.map((invitation) => { + // Process the role to handle comma-separated values + const roles = + typeof invitation.role === "string" && invitation.role.includes(",") + ? (invitation.role.split(",") as Role[]) + : Array.isArray(invitation.role) + ? invitation.role + : [invitation.role as Role]; - return { - ...invitation, - type: "invitation" as const, - displayName: invitation.email.split("@")[0], // Or just email - displayEmail: invitation.email, - displayRole: invitation.role, // Keep original for filtering - displayStatus: "pending" as const, - displayId: invitation.id, - // Add processed roles for rendering - processedRoles: roles, - }; - }), - ]; + return { + ...invitation, + type: "invitation" as const, + displayName: invitation.email.split("@")[0], // Or just email + displayEmail: invitation.email, + displayRole: invitation.role, // Keep original for filtering + displayStatus: "pending" as const, + displayId: invitation.id, + // Add processed roles for rendering + processedRoles: roles, + }; + }), + ]; - const filteredItems = allItems.filter((item) => { - const matchesSearch = - item.displayName.toLowerCase().includes(searchQuery.toLowerCase()) || - item.displayEmail.toLowerCase().includes(searchQuery.toLowerCase()); + const filteredItems = allItems.filter((item) => { + const matchesSearch = + item.displayName.toLowerCase().includes(searchQuery.toLowerCase()) || + item.displayEmail.toLowerCase().includes(searchQuery.toLowerCase()); - const matchesRole = - roleFilter === "all" || - (item.type === "member" && item.role === roleFilter) || - (item.type === "invitation" && item.role === roleFilter); + const matchesRole = + roleFilter === "all" || + (item.type === "member" && item.role === roleFilter) || + (item.type === "invitation" && item.role === roleFilter); - return matchesSearch && matchesRole; - }); + return matchesSearch && matchesRole; + }); - const activeMembers = filteredItems.filter((item) => item.type === "member"); - const pendingInvites = filteredItems.filter( - (item) => item.type === "invitation", - ); + const activeMembers = filteredItems.filter((item) => item.type === "member"); + const pendingInvites = filteredItems.filter( + (item) => item.type === "invitation", + ); - const handleCancelInvitation = async (invitationId: string) => { - const result = await revokeInvitationAction({ invitationId }); - if (result?.data) { - // Success case - // Data revalidates server-side via action's revalidatePath - router.refresh(); // Add client-side refresh as well - } else { - // Error case - const errorMessage = result?.serverError || "Failed to add user"; - console.error("Cancel Invitation Error:", errorMessage); - } - }; + const handleCancelInvitation = async (invitationId: string) => { + const result = await revokeInvitationAction({ invitationId }); + if (result?.data) { + // Success case + // Data revalidates server-side via action's revalidatePath + router.refresh(); // Add client-side refresh as well + } else { + // Error case + const errorMessage = result?.serverError || "Failed to add user"; + console.error("Cancel Invitation Error:", errorMessage); + } + }; - const handleRemoveMember = async (memberId: string) => { - const result = await removeMemberAction({ memberId }); - if (result?.data) { - // Success case - toast.success("has been removed from the organization"); - router.refresh(); // Add client-side refresh as well - } else { - // Error case - const errorMessage = result?.serverError || "Failed to remove member"; - console.error("Remove Member Error:", errorMessage); - toast.error(errorMessage); - } - }; + const handleRemoveMember = async (memberId: string) => { + const result = await removeMemberAction({ memberId }); + if (result?.data) { + // Success case + toast.success("has been removed from the organization"); + router.refresh(); // Add client-side refresh as well + } else { + // Error case + const errorMessage = result?.serverError || "Failed to remove member"; + console.error("Remove Member Error:", errorMessage); + toast.error(errorMessage); + } + }; - // Update handleUpdateRole to use authClient and add toasts - const handleUpdateRole = async (memberId: string, roles: Role[]) => { - const rolesArray = Array.isArray(roles) ? roles : [roles]; - const member = data.members.find((m) => m.id === memberId); + // Update handleUpdateRole to use authClient and add toasts + const handleUpdateRole = async (memberId: string, roles: Role[]) => { + const rolesArray = Array.isArray(roles) ? roles : [roles]; + const member = data.members.find((m) => m.id === memberId); - // Client-side check (optional, robust check should be server-side in authClient) - if (member && member.role === "owner" && !rolesArray.includes("owner")) { - // Show toast error directly, no need to return an error object - toast.error("The Owner role cannot be removed."); - return; - } + // Client-side check (optional, robust check should be server-side in authClient) + if (member && member.role === "owner" && !rolesArray.includes("owner")) { + // Show toast error directly, no need to return an error object + toast.error("The Owner role cannot be removed."); + return; + } - // Ensure at least one role is selected - if (rolesArray.length === 0) { - toast.warning("Please select at least one role."); - return; - } + // Ensure at least one role is selected + if (rolesArray.length === 0) { + toast.warning("Please select at least one role."); + return; + } - try { - // Use authClient directly - await authClient.organization.updateMemberRole({ - memberId: memberId, - role: rolesArray, // Pass the array of roles - }); - toast.success("Member roles updated successfully."); - router.refresh(); // Revalidate data - } catch (error) { - console.error("Update Role Error:", error); - // Attempt to get a meaningful error message from the caught error + try { + // Use authClient directly + await authClient.organization.updateMemberRole({ + memberId: memberId, + role: rolesArray, // Pass the array of roles + }); + toast.success("Member roles updated successfully."); + router.refresh(); // Revalidate data + } catch (error) { + console.error("Update Role Error:", error); + // Attempt to get a meaningful error message from the caught error - if (error instanceof Error) { - toast.error(error.message); - return; - } - toast.error("Failed to update member roles"); - } - }; + if (error instanceof Error) { + toast.error(error.message); + return; + } + toast.error("Failed to update member roles"); + } + }; - return ( -
- {/* Render the Invite Modal */} - + return ( +
+ {/* Render the Invite Modal */} + -
-
- setSearchQuery(e.target.value || null)} - leftIcon={} - /> - {searchQuery && ( - - )} -
- {/* Role Filter Select: Hidden on mobile, block on sm+ */} - - -
- - -
- {activeMembers.map((member) => ( - - ))} -
+
+
+ setSearchQuery(e.target.value || null)} + leftIcon={} + /> + {searchQuery && ( + + )} +
+ {/* Role Filter Select: Hidden on mobile, block on sm+ */} + + +
+ + +
+ {activeMembers.map((member) => ( + + ))} +
- {/* Conditionally render separator only if both sections have content */} - {activeMembers.length > 0 && pendingInvites.length > 0 && ( - - )} + {/* Conditionally render separator only if both sections have content */} + {activeMembers.length > 0 && pendingInvites.length > 0 && ( + + )} - {pendingInvites.length > 0 && ( -
- {pendingInvites.map((invitation) => ( - - ))} -
- )} + {pendingInvites.length > 0 && ( +
+ {pendingInvites.map((invitation) => ( + + ))} +
+ )} - {activeMembers.length === 0 && pendingInvites.length === 0 && ( -
- -

{"No employees yet"}

-

- {"Get started by inviting your first team member."} -

- -
- )} -
-
-
- ); + {activeMembers.length === 0 && pendingInvites.length === 0 && ( +
+ +

{"No employees yet"}

+

+ {"Get started by inviting your first team member."} +

+ +
+ )} + + +
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/people/all/loading.tsx b/apps/app/src/app/(app)/[orgId]/people/all/loading.tsx index fcb6e469a1..861d2c4553 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/loading.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/loading.tsx @@ -1,57 +1,57 @@ import { LogoSpinner } from "@/components/logo-spinner"; import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, } from "@comp/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@comp/ui/tabs"; export default async function Loading() { - return ( -
- - - - {"Members"} - - - {"Invite"} - - + return ( +
+ + + + {"Members"} + + + {"Invite"} + + - - - - {"Team Members"} - - - - - - - + + + + {"Team Members"} + + + + + + + - -
- - - {"Invite Members"} - - {"Invite new members to join your organization."} - - - - - -
-
-
- -
- - -
- ); + +
+ + + {"Invite Members"} + + {"Invite new members to join your organization."} + + + + + +
+
+
+ +
+ + +
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/people/all/page.tsx b/apps/app/src/app/(app)/[orgId]/people/all/page.tsx index 9e4cd0b964..d7bd130bb2 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/page.tsx @@ -4,15 +4,15 @@ import { Card } from "@comp/ui/card"; import PageCore from "@/components/pages/PageCore.tsx"; export default async function Members() { - return ( - - - - ); + return ( + + + + ); } export async function generateMetadata(): Promise { - return { - title: "People", - }; + return { + title: "People", + }; } diff --git a/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeeCompletionChart.tsx b/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeeCompletionChart.tsx index c323cc21af..7c5c4c7053 100644 --- a/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeeCompletionChart.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeeCompletionChart.tsx @@ -7,249 +7,234 @@ import type { CSSProperties } from "react"; // Use correct types from the database import { TrainingVideo } from "@/lib/data/training-videos"; import { - EmployeeTrainingVideoCompletion, - Member, - Policy, - User, + EmployeeTrainingVideoCompletion, + Member, + Policy, + User, } from "@comp/db/types"; interface EmployeeCompletionChartProps { - employees: (Member & { - user: User; - })[]; - policies: Policy[]; - trainingVideos: (EmployeeTrainingVideoCompletion & { - metadata: TrainingVideo; - })[]; + employees: (Member & { + user: User; + })[]; + policies: Policy[]; + trainingVideos: (EmployeeTrainingVideoCompletion & { + metadata: TrainingVideo; + })[]; } // Define colors for the chart const taskColors = { - completed: "bg-primary", // Green/Blue - incomplete: "bg-[var(--chart-open)]", // Yellow + completed: "bg-primary", // Green/Blue + incomplete: "bg-[var(--chart-open)]", // Yellow }; interface EmployeeTaskStats { - id: string; - name: string; - email: string; - totalTasks: number; - policiesCompleted: number; - trainingsCompleted: number; - policiesTotal: number; - trainingsTotal: number; - policyPercentage: number; - trainingPercentage: number; - overallPercentage: number; + id: string; + name: string; + email: string; + totalTasks: number; + policiesCompleted: number; + trainingsCompleted: number; + policiesTotal: number; + trainingsTotal: number; + policyPercentage: number; + trainingPercentage: number; + overallPercentage: number; } export function EmployeeCompletionChart({ - employees, - policies, - trainingVideos, + employees, + policies, + trainingVideos, }: EmployeeCompletionChartProps) { - // Calculate completion data for each employee - const employeeStats: EmployeeTaskStats[] = React.useMemo(() => { - return employees.map((employee) => { - // Count policies completed by this employee - const policiesCompletedCount = policies.filter((policy) => - policy.signedBy.includes(employee.id), - ).length; - - // Calculate policy completion percentage - const policyCompletionPercentage = policies.length - ? Math.round((policiesCompletedCount / policies.length) * 100) - : 0; - - // Count training videos completed by this employee - const employeeTrainingVideos = trainingVideos.filter( - (video) => - video.memberId === employee.id && - video.completedAt !== null, - ); - const trainingsCompletedCount = employeeTrainingVideos.length; - - // Get the total unique training videos available - const uniqueTrainingVideosIds = [ - ...new Set(trainingVideos.map((video) => video.metadata.id)), - ]; - const trainingVideosTotal = uniqueTrainingVideosIds.length; - - // Calculate training completion percentage - const trainingCompletionPercentage = trainingVideosTotal - ? Math.round( - (trainingsCompletedCount / trainingVideosTotal) * 100, - ) - : 0; - - // Calculate total completion percentage - const totalItems = policies.length + trainingVideosTotal; - const totalCompletedItems = - policiesCompletedCount + trainingsCompletedCount; - - const overallPercentage = totalItems - ? Math.round((totalCompletedItems / totalItems) * 100) - : 0; - - return { - id: employee.id, - name: employee.user.name || employee.user.email.split("@")[0], - email: employee.user.email, - totalTasks: totalItems, - policiesCompleted: policiesCompletedCount, - trainingsCompleted: trainingsCompletedCount, - policiesTotal: policies.length, - trainingsTotal: trainingVideosTotal, - policyPercentage: policyCompletionPercentage, - trainingPercentage: trainingCompletionPercentage, - overallPercentage, - }; - }); - }, [employees, policies, trainingVideos]); - - // Check for empty data scenarios - if (!employees.length) { - return ( - - - - {"Employee Task Completion"} - - - -

- {"No employee data available"} -

-
-
- ); - } - - // Check if there are any tasks to complete - if (policies.length === 0 && !trainingVideos.length) { - return ( - - - - {"Employee Task Completion"} - - - -

- {"No tasks available to complete"} -

-
-
- ); - } - - // Sort by completion percentage and limit to top 5 - const sortedStats = [...employeeStats] - .sort((a, b) => b.overallPercentage - a.overallPercentage) - .slice(0, 5); - - return ( - - - - {"Employee Task Completion"} - - - -
- {sortedStats.map((stat) => ( -
-
-

{stat.name}

- - {stat.policiesCompleted + - stat.trainingsCompleted}{" "} - / {stat.totalTasks} {"tasks"} - -
- - - -
-
-
- - {"Completed"} - -
-
-
- - {"Not Completed"} - -
-
-
- ))} -
- - - ); + // Calculate completion data for each employee + const employeeStats: EmployeeTaskStats[] = React.useMemo(() => { + return employees.map((employee) => { + // Count policies completed by this employee + const policiesCompletedCount = policies.filter((policy) => + policy.signedBy.includes(employee.id), + ).length; + + // Calculate policy completion percentage + const policyCompletionPercentage = policies.length + ? Math.round((policiesCompletedCount / policies.length) * 100) + : 0; + + // Count training videos completed by this employee + const employeeTrainingVideos = trainingVideos.filter( + (video) => video.memberId === employee.id && video.completedAt !== null, + ); + const trainingsCompletedCount = employeeTrainingVideos.length; + + // Get the total unique training videos available + const uniqueTrainingVideosIds = [ + ...new Set(trainingVideos.map((video) => video.metadata.id)), + ]; + const trainingVideosTotal = uniqueTrainingVideosIds.length; + + // Calculate training completion percentage + const trainingCompletionPercentage = trainingVideosTotal + ? Math.round((trainingsCompletedCount / trainingVideosTotal) * 100) + : 0; + + // Calculate total completion percentage + const totalItems = policies.length + trainingVideosTotal; + const totalCompletedItems = + policiesCompletedCount + trainingsCompletedCount; + + const overallPercentage = totalItems + ? Math.round((totalCompletedItems / totalItems) * 100) + : 0; + + return { + id: employee.id, + name: employee.user.name || employee.user.email.split("@")[0], + email: employee.user.email, + totalTasks: totalItems, + policiesCompleted: policiesCompletedCount, + trainingsCompleted: trainingsCompletedCount, + policiesTotal: policies.length, + trainingsTotal: trainingVideosTotal, + policyPercentage: policyCompletionPercentage, + trainingPercentage: trainingCompletionPercentage, + overallPercentage, + }; + }); + }, [employees, policies, trainingVideos]); + + // Check for empty data scenarios + if (!employees.length) { + return ( + + + {"Employee Task Completion"} + + +

+ {"No employee data available"} +

+
+
+ ); + } + + // Check if there are any tasks to complete + if (policies.length === 0 && !trainingVideos.length) { + return ( + + + {"Employee Task Completion"} + + +

+ {"No tasks available to complete"} +

+
+
+ ); + } + + // Sort by completion percentage and limit to top 5 + const sortedStats = [...employeeStats] + .sort((a, b) => b.overallPercentage - a.overallPercentage) + .slice(0, 5); + + return ( + + + {"Employee Task Completion"} + + +
+ {sortedStats.map((stat) => ( +
+
+

{stat.name}

+ + {stat.policiesCompleted + stat.trainingsCompleted} /{" "} + {stat.totalTasks} {"tasks"} + +
+ + + +
+
+
+ {"Completed"} +
+
+
+ {"Not Completed"} +
+
+
+ ))} +
+ + + ); } function TaskBarChart({ stat }: { stat: EmployeeTaskStats }) { - const totalCompleted = stat.policiesCompleted + stat.trainingsCompleted; - const totalIncomplete = stat.totalTasks - totalCompleted; - const barHeight = 12; - - // Empty chart for no tasks - if (stat.totalTasks === 0) { - return
; - } - - return ( -
-
- {/* Completed segment */} - {totalCompleted > 0 && ( -
-
-
- )} - - {/* Incomplete segment */} - {totalIncomplete > 0 && ( -
-
-
- )} -
-
- ); + const totalCompleted = stat.policiesCompleted + stat.trainingsCompleted; + const totalIncomplete = stat.totalTasks - totalCompleted; + const barHeight = 12; + + // Empty chart for no tasks + if (stat.totalTasks === 0) { + return
; + } + + return ( +
+
+ {/* Completed segment */} + {totalCompleted > 0 && ( +
+
+
+ )} + + {/* Incomplete segment */} + {totalIncomplete > 0 && ( +
+
+
+ )} +
+
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeesOverview.tsx b/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeesOverview.tsx index d213e932f2..502d36c573 100644 --- a/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeesOverview.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeesOverview.tsx @@ -7,102 +7,101 @@ import type { Member, Policy, User } from "@prisma/client"; // Define EmployeeWithUser type similar to EmployeesList interface EmployeeWithUser extends Member { - user: User; + user: User; } // Define ProcessedTrainingVideo type interface ProcessedTrainingVideo { - id: string; - memberId: string; - videoId: string; - completedAt: Date | null; - metadata: { - id: string; - title: string; - description: string; - youtubeId: string; - url: string; - }; + id: string; + memberId: string; + videoId: string; + completedAt: Date | null; + metadata: { + id: string; + title: string; + description: string; + youtubeId: string; + url: string; + }; } export async function EmployeesOverview() { - const session = await auth.api.getSession({ - headers: await headers(), - }); + const session = await auth.api.getSession({ + headers: await headers(), + }); - const organizationId = session?.session.activeOrganizationId; + const organizationId = session?.session.activeOrganizationId; - let employees: EmployeeWithUser[] = []; - let policies: Policy[] = []; - const processedTrainingVideos: ProcessedTrainingVideo[] = []; + let employees: EmployeeWithUser[] = []; + let policies: Policy[] = []; + const processedTrainingVideos: ProcessedTrainingVideo[] = []; - if (organizationId) { - // Fetch employees - const fetchedMembers = await db.member.findMany({ - where: { - organizationId: organizationId, - }, - include: { - user: true, - }, - }); + if (organizationId) { + // Fetch employees + const fetchedMembers = await db.member.findMany({ + where: { + organizationId: organizationId, + }, + include: { + user: true, + }, + }); - employees = fetchedMembers.filter((member) => { - const roles = member.role.includes(",") - ? member.role.split(",") - : [member.role]; - return roles.includes("employee"); - }); + employees = fetchedMembers.filter((member) => { + const roles = member.role.includes(",") + ? member.role.split(",") + : [member.role]; + return roles.includes("employee"); + }); - console.log(employees); + console.log(employees); - // Fetch required policies - policies = await db.policy.findMany({ - where: { - organizationId: organizationId, - isRequiredToSign: true, - }, - }); + // Fetch required policies + policies = await db.policy.findMany({ + where: { + organizationId: organizationId, + isRequiredToSign: true, + }, + }); - // Fetch and process training videos if employees exist - if (employees.length > 0) { - const employeeTrainingVideos = - await db.employeeTrainingVideoCompletion.findMany({ - where: { - memberId: { - in: employees.map((employee) => employee.id), - }, - }, - }); + // Fetch and process training videos if employees exist + if (employees.length > 0) { + const employeeTrainingVideos = + await db.employeeTrainingVideoCompletion.findMany({ + where: { + memberId: { + in: employees.map((employee) => employee.id), + }, + }, + }); - for (const dbVideo of employeeTrainingVideos) { - const videoMetadata = trainingVideosData.find( - (metadataVideo) => metadataVideo.id === dbVideo.videoId, - ); + for (const dbVideo of employeeTrainingVideos) { + const videoMetadata = trainingVideosData.find( + (metadataVideo) => metadataVideo.id === dbVideo.videoId, + ); - if (videoMetadata) { - // Push the object matching the updated ProcessedTrainingVideo interface - processedTrainingVideos.push({ - id: dbVideo.id, - memberId: dbVideo.memberId, - videoId: dbVideo.videoId, - completedAt: dbVideo.completedAt, - metadata: - videoMetadata as ProcessedTrainingVideo["metadata"], - }); - } - } - } - } + if (videoMetadata) { + // Push the object matching the updated ProcessedTrainingVideo interface + processedTrainingVideos.push({ + id: dbVideo.id, + memberId: dbVideo.memberId, + videoId: dbVideo.videoId, + completedAt: dbVideo.completedAt, + metadata: videoMetadata as ProcessedTrainingVideo["metadata"], + }); + } + } + } + } - return ( -
- -
- ); + return ( +
+ +
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/people/dashboard/page.tsx b/apps/app/src/app/(app)/[orgId]/people/dashboard/page.tsx index 6c8d23ab7d..c084cb9dbe 100644 --- a/apps/app/src/app/(app)/[orgId]/people/dashboard/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/dashboard/page.tsx @@ -2,11 +2,11 @@ import type { Metadata } from "next"; import { EmployeesOverview } from "./components/EmployeesOverview"; export default async function PeopleOverviewPage() { - return ; + return ; } export async function generateMetadata(): Promise { - return { - title: "People", - }; + return { + title: "People", + }; } diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.tsx new file mode 100644 index 0000000000..5187d71a17 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.tsx @@ -0,0 +1,192 @@ +"use client"; + +import * as React from "react"; +import { Label, Pie, PieChart, Cell } from "recharts"; +import { Info } from "lucide-react"; +import type { Host } from "../types"; +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from "@comp/ui/card"; +import { + type ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@comp/ui/chart"; + +interface DeviceComplianceChartProps { + devices: Host[]; +} + +const CHART_COLORS = { + compliant: "hsl(var(--chart-positive))", + nonCompliant: "hsl(var(--chart-destructive))", +}; + +export function DeviceComplianceChart({ devices }: DeviceComplianceChartProps) { + const { pieDisplayData, legendDisplayData } = React.useMemo(() => { + if (!devices || devices.length === 0) { + return { pieDisplayData: [], legendDisplayData: [] }; + } + let compliantCount = 0; + let nonCompliantCount = 0; + + for (const device of devices) { + const isCompliant = device.policies.every( + (policy) => policy.response === "pass", + ); + if (isCompliant) { + compliantCount++; + } else { + nonCompliantCount++; + } + } + const allItems = [ + { + name: "Compliant", + value: compliantCount, + fill: CHART_COLORS.compliant, + }, + { + name: "Non-Compliant", + value: nonCompliantCount, + fill: CHART_COLORS.nonCompliant, + }, + ]; + return { + pieDisplayData: allItems.filter((item) => item.value > 0), + legendDisplayData: allItems, + }; + }, [devices]); + + const totalDevices = React.useMemo(() => { + return devices?.length || 0; + }, [devices]); + + const chartConfig = { + devices: { + label: "Devices", + }, + compliant: { + label: "Compliant", + color: CHART_COLORS.compliant, + }, + nonCompliant: { + label: "Non-Compliant", + color: CHART_COLORS.nonCompliant, + }, + } satisfies ChartConfig; + + if (!devices || devices.length === 0) { + return ( + + + Device Compliance + + +
+

+ No device data available. Please make sure your employees access + the portal and install the device agent. +

+
+
+ +
+ + + ); + } + + return ( + + + Device Compliance + {/* Optional: Add a subtitle or small description here if needed */} + + + + + } + /> + + {pieDisplayData.map( + (entry: { name: string; value: number; fill: string }) => ( + + ), + )} + + + + + +
+ {legendDisplayData.map( + (item: { name: string; value: number; fill: string }) => ( +
+ + + {item.name} ({item.value}) + +
+ ), + )} +
+
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/EmployeeDevicesColumns.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/EmployeeDevicesColumns.tsx new file mode 100644 index 0000000000..69c8813e8a --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/EmployeeDevicesColumns.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"; +import type { ColumnDef } from "@tanstack/react-table"; +import { CheckCircle2, XCircle } from "lucide-react"; +import type { FleetPolicy, Host } from "../types"; + +export function getEmployeeDevicesColumns(): ColumnDef[] { + return [ + { + id: "computer_name", + accessorKey: "computer_name", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + return ( +
+ + {row.getValue("computer_name")} + +
+ ); + }, + }, + { + id: "policies", + accessorKey: "policies", + enableColumnFilter: false, + enableSorting: false, + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const policies = row.getValue("policies") as FleetPolicy[]; + const isCompliant = policies.every( + (policy) => policy.response === "pass", + ); + return isCompliant ? ( + + ) : ( + + ); + }, + }, + ]; +} diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/EmployeeDevicesList.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/EmployeeDevicesList.tsx new file mode 100644 index 0000000000..d001f741dd --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/EmployeeDevicesList.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { DataTable } from "@/components/data-table/data-table"; +import { DataTableToolbar } from "@/components/data-table/data-table-toolbar"; +import { useDataTable } from "@/hooks/use-data-table"; +import { useMemo, useState } from "react"; +import type { Host } from "../types/index"; +import { getEmployeeDevicesColumns } from "./EmployeeDevicesColumns"; +import { HostDetails } from "./HostDetails"; + +export const EmployeeDevicesList = ({ devices }: { devices: Host[] }) => { + const [selectedRow, setSelectedRow] = useState(null); + const columns = useMemo(() => getEmployeeDevicesColumns(), []); + + const { table } = useDataTable({ + data: devices, + columns, + pageCount: 1, + shallow: false, + clearOnDefault: true, + }); + + if (selectedRow) { + return ( + setSelectedRow(null)} /> + ); + } + + return ( + { + setSelectedRow(row); + }} + > + + + ); +}; diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/HostDetails.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/HostDetails.tsx new file mode 100644 index 0000000000..6a9c3b502d --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/HostDetails.tsx @@ -0,0 +1,59 @@ +import { Button } from "@comp/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@comp/ui/card"; +import type { Host } from "../types"; +import { ArrowLeft, CheckCircle2, XCircle } from "lucide-react"; +import { cn } from "@comp/ui/cn"; + +export const HostDetails = ({ + host, + onClose, +}: { + host: Host; + onClose: () => void; +}) => { + return ( +
+ + + + {host.computer_name}'s Policies + + + {host.policies.length > 0 ? ( + host.policies.map((policy) => ( +
+

{policy.name}

+ {policy.response === "pass" ? ( +
+ + Pass +
+ ) : ( +
+ + Fail +
+ )} +
+ )) + ) : ( +

+ No policies found for this device. +

+ )} +
+
+
+ ); +}; diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/data/index.ts b/apps/app/src/app/(app)/[orgId]/people/devices/data/index.ts new file mode 100644 index 0000000000..0212ee85b5 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/devices/data/index.ts @@ -0,0 +1,46 @@ +"use server"; + +import type { Host } from "../types"; +import { auth } from "@/utils/auth"; +import { db } from "@comp/db"; +import { headers } from "next/headers"; +import { getFleetInstance } from "@/lib/fleet"; + +export const getEmployeeDevices: () => Promise = async () => { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + const fleet = await getFleetInstance(); + + const organizationId = session?.session.activeOrganizationId; + + if (!organizationId) { + return null; + } + + const organization = await db.organization.findUnique({ + where: { + id: organizationId, + }, + }); + + if (!organization) { + return null; + } + + const labelId = organization.fleetDmLabelId; + + // Get all hosts to get their ids. + const employeeDevices = await fleet.get(`/labels/${labelId}/hosts`); + const allIds = employeeDevices.data.hosts.map( + (host: { id: number }) => host.id, + ); + + // Get all devices by id. in parallel + const devices = await Promise.all( + allIds.map((id: number) => fleet.get(`/hosts/${id}`)), + ); + + return devices.map((device: { data: { host: Host } }) => device.data.host); +}; diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/page.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/page.tsx new file mode 100644 index 0000000000..59214f93a7 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/devices/page.tsx @@ -0,0 +1,14 @@ +import { EmployeeDevicesList } from "./components/EmployeeDevicesList"; +import { DeviceComplianceChart } from "./components/DeviceComplianceChart"; +import { getEmployeeDevices } from "./data"; + +export default async function EmployeeDevicesPage() { + const devices = (await getEmployeeDevices()) || []; + + return ( +
+ + +
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/types/index.ts b/apps/app/src/app/(app)/[orgId]/people/devices/types/index.ts new file mode 100644 index 0000000000..4a85412058 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/devices/types/index.ts @@ -0,0 +1,82 @@ +export interface FleetPolicy { + id: number; + name: string; + query: string; + critical: boolean; + description: string; + author_id: number; + author_name: string; + author_email: string; + team_id: number | null; + resolution: string; + platform: string; + calendar_events_enabled: boolean; + created_at: string; // ISO date-time string + updated_at: string; // ISO date-time string + response: string; +} + +export interface Host { + created_at: string; + updated_at: string; + software: object[]; + software_updated_at: string; + id: number; + detail_updated_at: string; + label_updated_at: string; + policy_updated_at: string; + last_enrolled_at: string; + seen_time: string; + refetch_requested: boolean; + hostname: string; + uuid: string; + platform: string; + osquery_version: string; + orbit_version: string; + fleet_desktop_version: string; + scripts_enabled: boolean; + os_version: string; + build: string; + platform_like: string; + code_name: string; + uptime: number; + memory: number; + cpu_type: string; + cpu_subtype: string; + cpu_brand: string; + cpu_physical_cores: number; + cpu_logical_cores: number; + hardware_vendor: string; + hardware_model: string; + hardware_version: string; + hardware_serial: string; + computer_name: string; + public_ip: string; + primary_ip: string; + primary_mac: string; + distributed_interval: number; + config_tls_refresh: number; + logger_tls_period: number; + team_id: number | null; + pack_stats: object[]; + team_name: string | null; + users: object[]; + gigs_disk_space_available: number; + percent_disk_space_available: number; + gigs_total_disk_space: number; + disk_encryption_enabled: boolean; + issues: object; + mdm: object; + refetch_critical_queries_until: string | null; + last_restarted_at: string; + policies: FleetPolicy[]; + labels: object[]; + packs: object[]; + batteries: object[]; + end_users: object[]; + last_mdm_enrolled_at: string; + last_mdm_checked_in_at: string; + status: string; + display_text: string; + display_name: string; +} diff --git a/apps/app/src/app/(app)/[orgId]/people/layout.tsx b/apps/app/src/app/(app)/[orgId]/people/layout.tsx index 4eec8b964d..81ae45db09 100644 --- a/apps/app/src/app/(app)/[orgId]/people/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/layout.tsx @@ -1,3 +1,4 @@ +import { getPostHogClient } from "@/app/posthog"; import { AppOnboarding } from "@/components/app-onboarding"; import { auth } from "@/utils/auth"; import { db } from "@comp/db"; @@ -7,53 +8,66 @@ import { redirect } from "next/navigation"; import { Suspense } from "react"; export default async function Layout({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }) { - const session = await auth.api.getSession({ - headers: await headers(), - }); - const orgId = session?.session.activeOrganizationId; + const session = await auth.api.getSession({ + headers: await headers(), + }); + const orgId = session?.session.activeOrganizationId; - if (!orgId) { - return redirect("/"); - } + if (!orgId) { + return redirect("/"); + } - // Fetch all members first - const allMembers = await db.member.findMany({ - where: { - organizationId: orgId, - }, - }); + // Fetch all members first + const allMembers = await db.member.findMany({ + where: { + organizationId: orgId, + }, + }); - const employees = allMembers.filter((member) => { - const roles = member.role.includes(",") - ? member.role.split(",") - : [member.role]; - return roles.includes("employee"); - }); + const employees = allMembers.filter((member) => { + const roles = member.role.includes(",") + ? member.role.split(",") + : [member.role]; + return roles.includes("employee"); + }); - return ( -
- 0 - ? [ - { - path: `/${orgId}/people/dashboard`, - label: "Employee Tasks", - }, - ] - : []), - ]} - /> + const isFleetEnabled = await getPostHogClient()?.isFeatureEnabled( + "is-fleet-enabled", + session?.session.userId, + ); -
{children}
-
- ); + return ( +
+ 0 + ? [ + { + path: `/${orgId}/people/dashboard`, + label: "Employee Tasks", + }, + ] + : []), + ...(isFleetEnabled + ? [ + { + path: `/${orgId}/people/devices`, + label: "Employee Devices", + }, + ] + : []), + ]} + /> + +
{children}
+
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/people/loading.tsx b/apps/app/src/app/(app)/[orgId]/people/loading.tsx index e46c807a99..9636e0b23a 100644 --- a/apps/app/src/app/(app)/[orgId]/people/loading.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/loading.tsx @@ -2,16 +2,16 @@ import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; import { Suspense } from "react"; export default function Loading() { - return ( - - } - /> - ); + return ( + + } + /> + ); } diff --git a/apps/app/src/app/(app)/[orgId]/people/page.tsx b/apps/app/src/app/(app)/[orgId]/people/page.tsx index 54b15483ed..d3327388e6 100644 --- a/apps/app/src/app/(app)/[orgId]/people/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/page.tsx @@ -1,10 +1,10 @@ import { redirect } from "next/navigation"; export default async function Page({ - params, + params, }: { - params: Promise<{ locale: string, orgId: string }>; + params: Promise<{ locale: string; orgId: string }>; }) { - const { orgId } = await params; - return redirect(`/${orgId}/people/all`); + const { orgId } = await params; + return redirect(`/${orgId}/people/all`); } diff --git a/apps/app/src/app/(app)/[orgId]/people/types.ts b/apps/app/src/app/(app)/[orgId]/people/types.ts index ec59ac8f7c..b43dc872a0 100644 --- a/apps/app/src/app/(app)/[orgId]/people/types.ts +++ b/apps/app/src/app/(app)/[orgId]/people/types.ts @@ -1,44 +1,44 @@ import { z } from "zod"; export interface AppError { - code: string; - message: string; + code: string; + message: string; } export const appErrors = { - UNAUTHORIZED: { - code: "UNAUTHORIZED", - message: "You are not authorized to access this resource", - }, - UNEXPECTED_ERROR: { - code: "UNEXPECTED_ERROR", - message: "An unexpected error occurred", - }, + UNAUTHORIZED: { + code: "UNAUTHORIZED", + message: "You are not authorized to access this resource", + }, + UNEXPECTED_ERROR: { + code: "UNEXPECTED_ERROR", + message: "An unexpected error occurred", + }, }; export interface EmployeesInput { - search?: string; - role?: string; - page?: number; - per_page?: number; + search?: string; + role?: string; + page?: number; + per_page?: number; } export const employeesInputSchema = z.object({ - search: z.string().optional(), - role: z.string().optional(), - page: z.number().optional(), - per_page: z.number().optional(), + search: z.string().optional(), + role: z.string().optional(), + page: z.number().optional(), + per_page: z.number().optional(), }); export interface EmployeesResponse { - employees: any[]; - total: number; + employees: any[]; + total: number; } export interface Employee { - id: string; - name: string; - email: string; - department: string; - status: string; + id: string; + name: string; + email: string; + department: string; + status: string; } diff --git a/apps/app/src/app/(app)/[orgId]/policies/(overview)/components/policy-assignee-chart.tsx b/apps/app/src/app/(app)/[orgId]/policies/(overview)/components/policy-assignee-chart.tsx index f2c09c0681..6e808af06e 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/(overview)/components/policy-assignee-chart.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/(overview)/components/policy-assignee-chart.tsx @@ -4,244 +4,240 @@ import * as React from "react"; import { Badge } from "@comp/ui/badge"; import { - Card, - CardContent, - CardFooter, - CardHeader, - CardTitle, + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, } from "@comp/ui/card"; import { - type ChartConfig, - ChartContainer, - ChartTooltip, - ChartTooltipContent, + type ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, } from "@comp/ui/chart"; import { BarChart as BarChartIcon, Info, Users } from "lucide-react"; import { - Bar, - BarChart, - LabelList, - Legend, - ResponsiveContainer, - XAxis, - YAxis, + Bar, + BarChart, + LabelList, + Legend, + ResponsiveContainer, + XAxis, + YAxis, } from "recharts"; interface AssigneeData { - id: string; - name: string; - total: number; - published: number; - draft: number; - archived: number; - needs_review: number; + id: string; + name: string; + total: number; + published: number; + draft: number; + archived: number; + needs_review: number; } interface PolicyAssigneeChartProps { - data?: AssigneeData[] | null; + data?: AssigneeData[] | null; } const CHART_COLORS = { - published: "hsl(var(--chart-positive))", // green - draft: "hsl(var(--chart-neutral))", // yellow - archived: "hsl(var(--chart-warning))", // gray - needs_review: "hsl(var(--chart-destructive))", // red + published: "hsl(var(--chart-positive))", // green + draft: "hsl(var(--chart-neutral))", // yellow + archived: "hsl(var(--chart-warning))", // gray + needs_review: "hsl(var(--chart-destructive))", // red }; export function PolicyAssigneeChart({ data }: PolicyAssigneeChartProps) { - if (!data || data.length === 0) { - return ( - - -
- - {"Policies by Assignee"} - + if (!data || data.length === 0) { + return ( + + +
+ + {"Policies by Assignee"} + - - Distribution - -
-
- -
-
- -
-

- No policies assigned to users -

-
-
- -
- - - ); - } + + Distribution + +
+ + +
+
+ +
+

+ No policies assigned to users +

+
+
+ +
+ + + ); + } - // Sort assignees by total policies (descending) - const sortedData = React.useMemo(() => { - return [...data] - .sort((a, b) => b.total - a.total) - .slice(0, 4) - .reverse(); - }, [data]); + // Sort assignees by total policies (descending) + const sortedData = React.useMemo(() => { + return [...data] + .sort((a, b) => b.total - a.total) + .slice(0, 4) + .reverse(); + }, [data]); - const chartData = sortedData.map((item) => ({ - name: item.name, - published: item.published, - draft: item.draft, - archived: item.archived, - needs_review: item.needs_review, - })); + const chartData = sortedData.map((item) => ({ + name: item.name, + published: item.published, + draft: item.draft, + archived: item.archived, + needs_review: item.needs_review, + })); - const chartConfig = { - published: { - label: "Published", - color: CHART_COLORS.published, - }, - draft: { - label: "Draft", - color: CHART_COLORS.draft, - }, - archived: { - label: "Archived", - color: CHART_COLORS.archived, - }, - needs_review: { - label: "Needs Review", - color: CHART_COLORS.needs_review, - }, - } satisfies ChartConfig; + const chartConfig = { + published: { + label: "Published", + color: CHART_COLORS.published, + }, + draft: { + label: "Draft", + color: CHART_COLORS.draft, + }, + archived: { + label: "Archived", + color: CHART_COLORS.archived, + }, + needs_review: { + label: "Needs Review", + color: CHART_COLORS.needs_review, + }, + } satisfies ChartConfig; - // Calculate total policies and top assignee - const totalPolicies = React.useMemo(() => { - if (!data.length) return 0; - return data.reduce((sum, item) => sum + item.total, 0); - }, [data]); + // Calculate total policies and top assignee + const totalPolicies = React.useMemo(() => { + if (!data.length) return 0; + return data.reduce((sum, item) => sum + item.total, 0); + }, [data]); - const topAssignee = React.useMemo(() => { - if (!data.length) return null; - return data.reduce((prev, current) => - prev.total > current.total ? prev : current, - ); - }, [data]); + const topAssignee = React.useMemo(() => { + if (!data.length) return null; + return data.reduce((prev, current) => + prev.total > current.total ? prev : current, + ); + }, [data]); - return ( - - -
- - {"Policies by Assignee"} - - {topAssignee && ( - - Top: {topAssignee.name} - - )} -
+ return ( + + +
+ + {"Policies by Assignee"} + + {topAssignee && ( + + Top: {topAssignee.name} + + )} +
-
-
0 ? 100 : 0}%`, - opacity: 0.7, - }} - /> -
- - -
-
- Assignee - Policy Count -
- - - - - - value.split(" ")[0] - } - fontSize={12} - stroke="hsl(var(--muted-foreground))" - /> - } - /> - - - - - - - -
-
- -
- {Object.entries(chartConfig).map(([key, config]) => ( -
-
- - {config.label} - -
- ))} -
- - - ); +
+
0 ? 100 : 0}%`, + opacity: 0.7, + }} + /> +
+ + +
+
+ Assignee + Policy Count +
+ + + + + value.split(" ")[0]} + fontSize={12} + stroke="hsl(var(--muted-foreground))" + /> + } + /> + + + + + + + +
+
+ +
+ {Object.entries(chartConfig).map(([key, config]) => ( +
+
+ {config.label} +
+ ))} +
+ + + ); } diff --git a/apps/app/src/app/(app)/[orgId]/policies/(overview)/components/policy-status-chart.tsx b/apps/app/src/app/(app)/[orgId]/policies/(overview)/components/policy-status-chart.tsx index c5e8a50810..cb7e39f1e4 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/(overview)/components/policy-status-chart.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/(overview)/components/policy-status-chart.tsx @@ -5,253 +5,253 @@ import { Label, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts"; import { Badge } from "@comp/ui/badge"; import { - Card, - CardContent, - CardFooter, - CardHeader, - CardTitle, + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, } from "@comp/ui/card"; import { - type ChartConfig, - ChartContainer, - ChartTooltip, - ChartTooltipContent, + type ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, } from "@comp/ui/chart"; import { - BarChart as ChartIcon, - Info, - PieChart as PieChartIcon, + BarChart as ChartIcon, + Info, + PieChart as PieChartIcon, } from "lucide-react"; interface PolicyOverviewData { - totalPolicies: number; - publishedPolicies: number; - draftPolicies: number; - archivedPolicies: number; - needsReviewPolicies: number; + totalPolicies: number; + publishedPolicies: number; + draftPolicies: number; + archivedPolicies: number; + needsReviewPolicies: number; } interface PolicyStatusChartProps { - data?: PolicyOverviewData | null; + data?: PolicyOverviewData | null; } const CHART_COLORS = { - published: "hsl(var(--chart-positive))", // green - draft: "hsl(var(--chart-neutral))", // yellow - archived: "hsl(var(--chart-warning))", // gray - needs_review: "hsl(var(--chart-destructive))", // red + published: "hsl(var(--chart-positive))", // green + draft: "hsl(var(--chart-neutral))", // yellow + archived: "hsl(var(--chart-warning))", // gray + needs_review: "hsl(var(--chart-destructive))", // red }; // Custom tooltip component for the pie chart const StatusTooltip = ({ active, payload }: any) => { - if (active && payload && payload.length) { - const data = payload[0].payload; - return ( -
-

{data.name}

-

- Count: {data.value} -

-
- ); - } - return null; + if (active && payload && payload.length) { + const data = payload[0].payload; + return ( +
+

{data.name}

+

+ Count: {data.value} +

+
+ ); + } + return null; }; export function PolicyStatusChart({ data }: PolicyStatusChartProps) { - if (!data) { - return ( - - -
- - {"Policy by Status"} - - - Overview - -
-
- -
-
- -
-

- No policy data available -

-
-
- -
- - - ); - } + if (!data) { + return ( + + +
+ + {"Policy by Status"} + + + Overview + +
+
+ +
+
+ +
+

+ No policy data available +

+
+
+ +
+ + + ); + } - const chartData = React.useMemo(() => { - const items = [ - { - name: "Published", - value: data.publishedPolicies, - fill: CHART_COLORS.published, - }, - { - name: "Draft", - value: data.draftPolicies, - fill: CHART_COLORS.draft, - }, - { - name: "Archived", - value: data.archivedPolicies, - fill: CHART_COLORS.archived, - }, - { - name: "Needs Review", - value: data.needsReviewPolicies, - fill: CHART_COLORS.needs_review, - }, - ]; + const chartData = React.useMemo(() => { + const items = [ + { + name: "Published", + value: data.publishedPolicies, + fill: CHART_COLORS.published, + }, + { + name: "Draft", + value: data.draftPolicies, + fill: CHART_COLORS.draft, + }, + { + name: "Archived", + value: data.archivedPolicies, + fill: CHART_COLORS.archived, + }, + { + name: "Needs Review", + value: data.needsReviewPolicies, + fill: CHART_COLORS.needs_review, + }, + ]; - return items.filter((item) => item.value); - }, [data]); + return items.filter((item) => item.value); + }, [data]); - const chartConfig = { - value: { - label: "Count", - }, - } satisfies ChartConfig; + const chartConfig = { + value: { + label: "Count", + }, + } satisfies ChartConfig; - // Calculate most common status - const mostCommonStatus = React.useMemo(() => { - if (!chartData.length) return null; - return chartData.reduce((prev, current) => - prev.value > current.value ? prev : current, - ); - }, [chartData]); + // Calculate most common status + const mostCommonStatus = React.useMemo(() => { + if (!chartData.length) return null; + return chartData.reduce((prev, current) => + prev.value > current.value ? prev : current, + ); + }, [chartData]); - return ( - - -
- - {"Policy by Status"} - + return ( + + +
+ + {"Policy by Status"} + - {data.totalPolicies > 0 && mostCommonStatus && ( - - Most: {mostCommonStatus.name} - - )} -
+ {data.totalPolicies > 0 && mostCommonStatus && ( + + Most: {mostCommonStatus.name} + + )} +
-
-
-
- - - - - } /> - - - - - - -
- {chartData.map((entry) => ( -
-
- - {entry.name} - - ({entry.value}) - - -
- ))} -
- - - ); +
+
+
+ + + + + } /> + + + + + + +
+ {chartData.map((entry) => ( +
+
+ + {entry.name} + + ({entry.value}) + + +
+ ))} +
+ + + ); } diff --git a/apps/app/src/app/(app)/[orgId]/policies/(overview)/loading.tsx b/apps/app/src/app/(app)/[orgId]/policies/(overview)/loading.tsx index d832086871..bbed1da46e 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/(overview)/loading.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/(overview)/loading.tsx @@ -2,31 +2,27 @@ import { LogoSpinner } from "@/components/logo-spinner"; import { Card, CardContent, CardHeader, CardTitle } from "@comp/ui/card"; export default async function Loading() { - return ( -
-
- - - - {"Policy by Status"} - - - - - - + return ( +
+
+ + + {"Policy by Status"} + + + + + - - - - {"Policies by Assignee"} - - - - - - -
-
- ); + + + {"Policies by Assignee"} + + + + + +
+
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/policies/(overview)/page.tsx b/apps/app/src/app/(app)/[orgId]/policies/(overview)/page.tsx index 1be8e7610b..3fb1ddca54 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/(overview)/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/(overview)/page.tsx @@ -8,155 +8,155 @@ import { PolicyStatusChart } from "./components/policy-status-chart"; import Loading from "./loading"; export default async function PoliciesOverview() { - const overview = await getPoliciesOverview(); + const overview = await getPoliciesOverview(); - return ( - }> -
-
- - -
-
-
- ); + return ( + }> +
+
+ + +
+
+
+ ); } const getPoliciesOverview = async () => { - const session = await auth.api.getSession({ - headers: await headers(), - }); + const session = await auth.api.getSession({ + headers: await headers(), + }); - if (!session?.session?.activeOrganizationId) { - return null; - } + if (!session?.session?.activeOrganizationId) { + return null; + } - const organizationId = session.session.activeOrganizationId; + const organizationId = session.session.activeOrganizationId; - return await db.$transaction(async (tx) => { - const [ - totalPolicies, - publishedPolicies, - draftPolicies, - archivedPolicies, - needsReviewPolicies, - policiesByAssignee, - policiesByAssigneeStatus, - ] = await Promise.all([ - tx.policy.count({ - where: { - organizationId, - }, - }), - tx.policy.count({ - where: { - organizationId, - status: "published", - isArchived: false, - }, - }), - tx.policy.count({ - where: { - organizationId, - status: "draft", - isArchived: false, - }, - }), - tx.policy.count({ - where: { - organizationId, - isArchived: true, - }, - }), - tx.policy.count({ - where: { - organizationId, - status: "needs_review", - isArchived: false, - }, - }), - tx.policy.groupBy({ - by: ["assigneeId"], - _count: true, - where: { - organizationId, - assigneeId: { not: null }, - }, - }), - tx.policy.findMany({ - where: { - organizationId, - assigneeId: { not: null }, - }, - select: { - status: true, - isArchived: true, - assignee: { - select: { - id: true, - user: { - select: { - name: true, - }, - }, - }, - }, - }, - }), - ]); + return await db.$transaction(async (tx) => { + const [ + totalPolicies, + publishedPolicies, + draftPolicies, + archivedPolicies, + needsReviewPolicies, + policiesByAssignee, + policiesByAssigneeStatus, + ] = await Promise.all([ + tx.policy.count({ + where: { + organizationId, + }, + }), + tx.policy.count({ + where: { + organizationId, + status: "published", + isArchived: false, + }, + }), + tx.policy.count({ + where: { + organizationId, + status: "draft", + isArchived: false, + }, + }), + tx.policy.count({ + where: { + organizationId, + isArchived: true, + }, + }), + tx.policy.count({ + where: { + organizationId, + status: "needs_review", + isArchived: false, + }, + }), + tx.policy.groupBy({ + by: ["assigneeId"], + _count: true, + where: { + organizationId, + assigneeId: { not: null }, + }, + }), + tx.policy.findMany({ + where: { + organizationId, + assigneeId: { not: null }, + }, + select: { + status: true, + isArchived: true, + assignee: { + select: { + id: true, + user: { + select: { + name: true, + }, + }, + }, + }, + }, + }), + ]); - // Transform the data for easier consumption by the chart component - // First group by owner - const policyDataByOwner = new Map(); + // Transform the data for easier consumption by the chart component + // First group by owner + const policyDataByOwner = new Map(); - for (const policy of policiesByAssigneeStatus) { - if (!policy.assignee) continue; + for (const policy of policiesByAssigneeStatus) { + if (!policy.assignee) continue; - const assigneeId = policy.assignee.id; - if (!policyDataByOwner.has(assigneeId)) { - policyDataByOwner.set(assigneeId, { - id: assigneeId, - name: policy.assignee.user.name || "Unknown", - total: 0, - published: 0, - draft: 0, - archived: 0, - needs_review: 0, - }); - } + const assigneeId = policy.assignee.id; + if (!policyDataByOwner.has(assigneeId)) { + policyDataByOwner.set(assigneeId, { + id: assigneeId, + name: policy.assignee.user.name || "Unknown", + total: 0, + published: 0, + draft: 0, + archived: 0, + needs_review: 0, + }); + } - const assigneeData = policyDataByOwner.get(assigneeId); - assigneeData.total += 1; + const assigneeData = policyDataByOwner.get(assigneeId); + assigneeData.total += 1; - // Handle archived policies separately - if (policy.isArchived) { - assigneeData.archived += 1; - continue; - } + // Handle archived policies separately + if (policy.isArchived) { + assigneeData.archived += 1; + continue; + } - // Handle each status type explicitly - const status = policy.status; - if (status === "published") assigneeData.published += 1; - else if (status === "draft") assigneeData.draft += 1; - else if (status === "needs_review") assigneeData.needs_review += 1; - } + // Handle each status type explicitly + const status = policy.status; + if (status === "published") assigneeData.published += 1; + else if (status === "draft") assigneeData.draft += 1; + else if (status === "needs_review") assigneeData.needs_review += 1; + } - const assigneeData = Array.from(policyDataByOwner.values()); + const assigneeData = Array.from(policyDataByOwner.values()); - return { - totalPolicies, - publishedPolicies, - draftPolicies, - archivedPolicies, - needsReviewPolicies, - policiesByAssignee, - assigneeData, - }; - }); + return { + totalPolicies, + publishedPolicies, + draftPolicies, + archivedPolicies, + needsReviewPolicies, + policiesByAssignee, + assigneeData, + }; + }); }; export async function generateMetadata(): Promise { - return { - title: "Policies", - }; + return { + title: "Policies", + }; } diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/actions/mapPolicyToControls.ts b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/actions/mapPolicyToControls.ts index 3d1de58f81..a50c2499ce 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/actions/mapPolicyToControls.ts +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/actions/mapPolicyToControls.ts @@ -7,63 +7,66 @@ import { revalidatePath } from "next/cache"; import { headers } from "next/headers"; const mapPolicyToControlsSchema = z.object({ - policyId: z.string(), - controlIds: z.array(z.string()), + policyId: z.string(), + controlIds: z.array(z.string()), }); export const mapPolicyToControls = authActionClient - .schema(mapPolicyToControlsSchema) - .metadata({ - name: "map-policy-to-controls", - track: { - event: "map-policy-to-controls", - channel: "server", - }, - }) - .action(async ({ parsedInput, ctx }) => { - const { policyId, controlIds } = parsedInput; - const { session } = ctx; + .schema(mapPolicyToControlsSchema) + .metadata({ + name: "map-policy-to-controls", + track: { + event: "map-policy-to-controls", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { policyId, controlIds } = parsedInput; + const { session } = ctx; - if (!session.activeOrganizationId) { - return { - success: false, - error: "Not authorized", - }; - } + if (!session.activeOrganizationId) { + return { + success: false, + error: "Not authorized", + }; + } - try { - console.log(`Mapping controls ${controlIds} to policy ${policyId}`); + try { + console.log(`Mapping controls ${controlIds} to policy ${policyId}`); - // Update the policy to connect it to the specified controls - const updatedPolicy = await db.policy.update({ - where: { id: policyId, organizationId: session.activeOrganizationId }, - data: { - controls: { - connect: controlIds.map((id) => ({ id })), - }, - }, - include: { // Optional: include controls to verify or log - controls: true, - } - }); + // Update the policy to connect it to the specified controls + const updatedPolicy = await db.policy.update({ + where: { id: policyId, organizationId: session.activeOrganizationId }, + data: { + controls: { + connect: controlIds.map((id) => ({ id })), + }, + }, + include: { + // Optional: include controls to verify or log + controls: true, + }, + }); - console.log("Policy updated with controls:", updatedPolicy.controls); - console.log(`Controls mapped successfully to policy ${policyId}`); + console.log("Policy updated with controls:", updatedPolicy.controls); + console.log(`Controls mapped successfully to policy ${policyId}`); - const headersList = await headers(); - let path = headersList.get("x-pathname") || headersList.get("referer") || ""; - path = path.replace(/\/[a-z]{2}\//, "/"); - revalidatePath(path); + const headersList = await headers(); + let path = + headersList.get("x-pathname") || headersList.get("referer") || ""; + path = path.replace(/\/[a-z]{2}\//, "/"); + revalidatePath(path); - return { - success: true, - data: updatedPolicy.controls, - }; - } catch (error) { - console.error("Error mapping controls to policy:", error); - return { - success: false, - error: error instanceof Error ? error.message : "Failed to map controls", - }; - } - }); + return { + success: true, + data: updatedPolicy.controls, + }; + } catch (error) { + console.error("Error mapping controls to policy:", error); + return { + success: false, + error: + error instanceof Error ? error.message : "Failed to map controls", + }; + } + }); diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/actions/unmapPolicyFromControl.ts b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/actions/unmapPolicyFromControl.ts index d71f0fd707..2372e4647c 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/actions/unmapPolicyFromControl.ts +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/actions/unmapPolicyFromControl.ts @@ -7,57 +7,59 @@ import { revalidatePath } from "next/cache"; import { headers } from "next/headers"; const unmapPolicyFromControlSchema = z.object({ - policyId: z.string(), - controlId: z.string(), + policyId: z.string(), + controlId: z.string(), }); export const unmapPolicyFromControl = authActionClient - .schema(unmapPolicyFromControlSchema) - .metadata({ - name: "unmap-policy-from-control", - track: { - event: "unmap-policy-from-control", - channel: "server", - }, - }) - .action(async ({ parsedInput, ctx }) => { - const { policyId, controlId } = parsedInput; - const { session } = ctx; - - if (!session.activeOrganizationId) { - return { - success: false, - error: "Not authorized", - }; - } - - try { - console.log(`Unmapping control ${controlId} from policy ${policyId}`); - - // Update the policy to disconnect it from the specified control - await db.policy.update({ - where: { id: policyId, organizationId: session.activeOrganizationId }, - data: { - controls: { - disconnect: { id: controlId }, - }, - }, - }); - - console.log(`Control ${controlId} unmapped from policy ${policyId}`); - const headersList = await headers(); - let path = headersList.get("x-pathname") || headersList.get("referer") || ""; - path = path.replace(/\/[a-z]{2}\//, "/"); - revalidatePath(path); - - return { - success: true, - }; - } catch (error) { - console.error("Error unmapping control from policy:", error); - return { - success: false, - error: error instanceof Error ? error.message : "Failed to unmap control", - }; - } - }); + .schema(unmapPolicyFromControlSchema) + .metadata({ + name: "unmap-policy-from-control", + track: { + event: "unmap-policy-from-control", + channel: "server", + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { policyId, controlId } = parsedInput; + const { session } = ctx; + + if (!session.activeOrganizationId) { + return { + success: false, + error: "Not authorized", + }; + } + + try { + console.log(`Unmapping control ${controlId} from policy ${policyId}`); + + // Update the policy to disconnect it from the specified control + await db.policy.update({ + where: { id: policyId, organizationId: session.activeOrganizationId }, + data: { + controls: { + disconnect: { id: controlId }, + }, + }, + }); + + console.log(`Control ${controlId} unmapped from policy ${policyId}`); + const headersList = await headers(); + let path = + headersList.get("x-pathname") || headersList.get("referer") || ""; + path = path.replace(/\/[a-z]{2}\//, "/"); + revalidatePath(path); + + return { + success: true, + }; + } catch (error) { + console.error("Error unmapping control from policy:", error); + return { + success: false, + error: + error instanceof Error ? error.message : "Failed to unmap control", + }; + } + }); diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyActionDialog.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyActionDialog.tsx index 526c79f917..e6ce80150f 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyActionDialog.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyActionDialog.tsx @@ -32,7 +32,13 @@ interface PolicyActionDialogProps { description: string; confirmText: string; confirmIcon: React.ReactNode; - confirmVariant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"; + confirmVariant?: + | "default" + | "destructive" + | "outline" + | "secondary" + | "ghost" + | "link"; } export function PolicyActionDialog({ @@ -73,7 +79,10 @@ export function PolicyActionDialog({ {description}
- + { - if (result) { - toast.success("Policy archived successfully"); - // Redirect to policies list after successful archive - router.push(`/${policy.organizationId}/policies/all`); - } else { - toast.success("Policy restored successfully"); - // Stay on the policy page after restore - router.refresh(); - } - handleOpenChange(false); - }, - onError: () => { - toast.error("Failed to update policy archive status"); - }, - }); + const archivePolicy = useAction(archivePolicyAction, { + onSuccess: (result) => { + if (result) { + toast.success("Policy archived successfully"); + // Redirect to policies list after successful archive + router.push(`/${policy.organizationId}/policies/all`); + } else { + toast.success("Policy restored successfully"); + // Stay on the policy page after restore + router.refresh(); + } + handleOpenChange(false); + }, + onError: () => { + toast.error("Failed to update policy archive status"); + }, + }); - const handleOpenChange = (open: boolean) => { - setOpen(open ? "true" : null); - }; + const handleOpenChange = (open: boolean) => { + setOpen(open ? "true" : null); + }; - const handleAction = () => { - archivePolicy.execute({ - id: policy.id, - action: isArchived ? "restore" : "archive", - entityId: policy.id, - }); - }; + const handleAction = () => { + archivePolicy.execute({ + id: policy.id, + action: isArchived ? "restore" : "archive", + entityId: policy.id, + }); + }; - const content = ( -
-

- {isArchived - ? "Are you sure you want to restore this policy?" - : "Are you sure you want to archive this policy?"} -

-
- - -
-
- ); + const content = ( +
+

+ {isArchived + ? "Are you sure you want to restore this policy?" + : "Are you sure you want to archive this policy?"} +

+
+ + +
+
+ ); - if (isDesktop) { - return ( - - - -
- - {isArchived - ? "Restore Policy" - : "Archive Policy"} - - -
- {policy.name} -
- {content} -
-
- ); - } + if (isDesktop) { + return ( + + + +
+ + {isArchived ? "Restore Policy" : "Archive Policy"} + + +
+ {policy.name} +
+ {content} +
+
+ ); + } - return ( - - - -
-

- {isArchived - ? "Restore Policy" - : "Archive Policy"} -

-

- {policy.name} -

-
- {content} -
-
- ); + return ( + + + +
+

+ {isArchived ? "Restore Policy" : "Archive Policy"} +

+

{policy.name}

+
+ {content} +
+
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyControlMappingConfirmDeleteModal.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyControlMappingConfirmDeleteModal.tsx index e3f217f3fa..d065d11d59 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyControlMappingConfirmDeleteModal.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyControlMappingConfirmDeleteModal.tsx @@ -1,13 +1,13 @@ import type { Control } from "@comp/db/types"; import { useState } from "react"; import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, } from "@comp/ui/dialog"; import { Button } from "@comp/ui/button"; import { X } from "lucide-react"; @@ -16,61 +16,61 @@ import { toast } from "sonner"; import { useParams } from "next/navigation"; export const PolicyControlMappingConfirmDeleteModal = ({ - control, + control, }: { - control: Control; + control: Control; }) => { - const { policyId } = useParams<{ policyId: string }>(); - const [open, setOpen] = useState(false); - const [loading, setLoading] = useState(false); + const { policyId } = useParams<{ policyId: string }>(); + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); - const handleUnmap = async () => { - console.log("Unmapping control", control.id, "from policy", policyId); - try { - setLoading(true); - await unmapPolicyFromControl({ - policyId, - controlId: control.id, - }); - toast.success( - `Control: ${control.name} unmapped successfully from policy ${policyId}`, - ); - } catch (error) { - console.error(error); - toast.error("Failed to unlink control"); - } finally { - setLoading(false); - setOpen(false); - } - }; + const handleUnmap = async () => { + console.log("Unmapping control", control.id, "from policy", policyId); + try { + setLoading(true); + await unmapPolicyFromControl({ + policyId, + controlId: control.id, + }); + toast.success( + `Control: ${control.name} unmapped successfully from policy ${policyId}`, + ); + } catch (error) { + console.error(error); + toast.error("Failed to unlink control"); + } finally { + setLoading(false); + setOpen(false); + } + }; - return ( - - - - - - - Confirm Unlink - - - Are you sure you want to unlink{" "} - {control.name}{" "} - from this policy? {"\n"} You can link it back again later. - - - - - - - - ); + return ( + + + + + + + Confirm Unlink + + + Are you sure you want to unlink{" "} + {control.name}{" "} + from this policy? {"\n"} You can link it back again later. + + + + + + + + ); }; diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyControlMappingModal.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyControlMappingModal.tsx index c8a0ce7e2a..553c262b0c 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyControlMappingModal.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyControlMappingModal.tsx @@ -1,13 +1,13 @@ import { Control } from "@comp/db/types"; import { useEffect, useState } from "react"; import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, } from "@comp/ui/dialog"; import { Badge } from "@comp/ui/badge"; import { Button } from "@comp/ui/button"; @@ -18,102 +18,98 @@ import { useParams } from "next/navigation"; import { toast } from "sonner"; export const PolicyControlMappingModal = ({ - allControls, - mappedControls, + allControls, + mappedControls, }: { - allControls: Control[]; - mappedControls: Control[]; + allControls: Control[]; + mappedControls: Control[]; }) => { - const [open, setOpen] = useState(false); - const mappedControlIds = new Set(mappedControls.map((c) => c.id)); - const [selectedControls, setSelectedControls] = useState([]); - const { policyId } = useParams<{ policyId: string }>(); + const [open, setOpen] = useState(false); + const mappedControlIds = new Set(mappedControls.map((c) => c.id)); + const [selectedControls, setSelectedControls] = useState([]); + const { policyId } = useParams<{ policyId: string }>(); - // Filter out controls that are already mapped - const filteredControls = allControls.filter( - (control) => !mappedControlIds.has(control.id), - ); + // Filter out controls that are already mapped + const filteredControls = allControls.filter( + (control) => !mappedControlIds.has(control.id), + ); - // Prepare options for the MultipleSelector - const preparedOptions = filteredControls.map((control) => ({ - value: control.id, - label: control.name, - })); + // Prepare options for the MultipleSelector + const preparedOptions = filteredControls.map((control) => ({ + value: control.id, + label: control.name, + })); - const handleMapControls = async () => { - try { - console.log( - `Mapping controls ${selectedControls.map((c) => c.label)} to policy ${policyId}`, - ); - await mapPolicyToControls({ - policyId, - controlIds: selectedControls.map((c) => c.value), - }); - setOpen(false); - toast.success( - `Controls ${selectedControls.map((c) => c.label)} mapped successfully to policy ${policyId}`, - ); - } catch (error) { - console.error(error); - toast.error("Failed to map controls"); - } - }; + const handleMapControls = async () => { + try { + console.log( + `Mapping controls ${selectedControls.map((c) => c.label)} to policy ${policyId}`, + ); + await mapPolicyToControls({ + policyId, + controlIds: selectedControls.map((c) => c.value), + }); + setOpen(false); + toast.success( + `Controls ${selectedControls.map((c) => c.label)} mapped successfully to policy ${policyId}`, + ); + } catch (error) { + console.error(error); + toast.error("Failed to map controls"); + } + }; - useEffect(() => { - return () => { - setSelectedControls([]); - }; - }, [open]); + useEffect(() => { + return () => { + setSelectedControls([]); + }; + }, [open]); - return ( - - - setOpen(true)} - > - - Link Controls - - - - - Link New Controls - - - Select controls you want to link to this policy - - { - // Find the option with this value - const option = preparedOptions.find( - (opt) => opt.value === value, - ); - if (!option) return 0; + return ( + + + setOpen(true)} + > + + Link Controls + + + + + Link New Controls + + + Select controls you want to link to this policy + + { + // Find the option with this value + const option = preparedOptions.find((opt) => opt.value === value); + if (!option) return 0; - // Check if the option label contains the search string - return option.label - .toLowerCase() - .includes(search.toLowerCase()) - ? 1 - : 0; - }, - }} - /> - - - - - - - ); + // Check if the option label contains the search string + return option.label.toLowerCase().includes(search.toLowerCase()) + ? 1 + : 0; + }, + }} + /> + + + + + + + ); }; diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyControlMappings.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyControlMappings.tsx index c02774e415..0ec9ed6fc1 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyControlMappings.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyControlMappings.tsx @@ -1,5 +1,11 @@ import { Control } from "@comp/db/types"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@comp/ui/card"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@comp/ui/card"; import { SelectPills } from "@comp/ui/select-pills"; import { toast } from "sonner"; import { mapPolicyToControls } from "../actions/mapPolicyToControls"; @@ -9,83 +15,89 @@ import { useAction } from "next-safe-action/hooks"; import { useState } from "react"; export const PolicyControlMappings = ({ - mappedControls, - allControls, - isPendingApproval, + mappedControls, + allControls, + isPendingApproval, }: { - mappedControls: Control[]; - allControls: Control[]; - isPendingApproval: boolean; + mappedControls: Control[]; + allControls: Control[]; + isPendingApproval: boolean; }) => { - const { policyId } = useParams<{ policyId: string }>(); - const [loading, setLoading] = useState(false); + const { policyId } = useParams<{ policyId: string }>(); + const [loading, setLoading] = useState(false); - const mapControlsAction = useAction(mapPolicyToControls, { - onSuccess: () => { - toast.success("Controls mapped successfully"); - }, - onError: (err) => { - toast.error(err.error.serverError || "Failed to map controls"); - setLoading(false); - }, - }); + const mapControlsAction = useAction(mapPolicyToControls, { + onSuccess: () => { + toast.success("Controls mapped successfully"); + }, + onError: (err) => { + toast.error(err.error.serverError || "Failed to map controls"); + setLoading(false); + }, + }); - const unmapControlAction = useAction(unmapPolicyFromControl, { - onSuccess: () => { - toast.success("Controls unmapped successfully"); - setLoading(false); - }, - onError: (err) => { - toast.error(err.error.serverError || "Failed to unmap control"); - setLoading(false); - }, - }); + const unmapControlAction = useAction(unmapPolicyFromControl, { + onSuccess: () => { + toast.success("Controls unmapped successfully"); + setLoading(false); + }, + onError: (err) => { + toast.error(err.error.serverError || "Failed to unmap control"); + setLoading(false); + }, + }); - const mappedNames = mappedControls.map((c) => c.name); + const mappedNames = mappedControls.map((c) => c.name); - const handleValueChange = async (selectedNames: string[]) => { - if (isPendingApproval || loading) return; - setLoading(true); - const prevIds = mappedControls.map((c) => c.id); - const nextControls = allControls.filter((c) => selectedNames.includes(c.name)); - const nextIds = nextControls.map((c) => c.id); + const handleValueChange = async (selectedNames: string[]) => { + if (isPendingApproval || loading) return; + setLoading(true); + const prevIds = mappedControls.map((c) => c.id); + const nextControls = allControls.filter((c) => + selectedNames.includes(c.name), + ); + const nextIds = nextControls.map((c) => c.id); - const added = nextControls.filter((c) => !prevIds.includes(c.id)); - const removed = mappedControls.filter((c) => !nextIds.includes(c.id)); + const added = nextControls.filter((c) => !prevIds.includes(c.id)); + const removed = mappedControls.filter((c) => !nextIds.includes(c.id)); - try { - if (added.length > 0) { - await mapControlsAction.execute({ policyId, controlIds: added.map((c) => c.id) }); - } - if (removed.length > 0) { - await unmapControlAction.execute({ policyId, controlId: removed[0].id }); - } - } catch (err) { - toast.error("Failed to update controls"); - } finally { - setLoading(false); - } - }; + try { + if (added.length > 0) { + await mapControlsAction.execute({ + policyId, + controlIds: added.map((c) => c.id), + }); + } + if (removed.length > 0) { + await unmapControlAction.execute({ + policyId, + controlId: removed[0].id, + }); + } + } catch (err) { + toast.error("Failed to update controls"); + } finally { + setLoading(false); + } + }; - return ( - - - - Map Controls - - - Map controls that are relevant to this policy. - - - - ({ id: c.id, name: c.name }))} - value={mappedNames} - onValueChange={handleValueChange} - placeholder="Search controls..." - disabled={isPendingApproval || loading} - /> - - - ); + return ( + + + Map Controls + + Map controls that are relevant to this policy. + + + + ({ id: c.id, name: c.name }))} + value={mappedNames} + onValueChange={handleValueChange} + placeholder="Search controls..." + disabled={isPendingApproval || loading} + /> + + + ); }; diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyDeleteDialog.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyDeleteDialog.tsx index 1b16b2a5bc..554b5210b2 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyDeleteDialog.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyDeleteDialog.tsx @@ -4,12 +4,12 @@ import { deletePolicyAction } from "@/actions/policies/delete-policy"; import { Policy } from "@comp/db/types"; import { Button } from "@comp/ui/button"; import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, } from "@comp/ui/dialog"; import { Form } from "@comp/ui/form"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -22,97 +22,97 @@ import { toast } from "sonner"; import { z } from "zod"; const formSchema = z.object({ - comment: z.string().optional(), + comment: z.string().optional(), }); type FormValues = z.infer; interface PolicyDeleteDialogProps { - isOpen: boolean; - onClose: () => void; - policy: Policy; + isOpen: boolean; + onClose: () => void; + policy: Policy; } export function PolicyDeleteDialog({ - isOpen, - onClose, - policy, + isOpen, + onClose, + policy, }: PolicyDeleteDialogProps) { - const router = useRouter(); - const [isSubmitting, setIsSubmitting] = useState(false); + const router = useRouter(); + const [isSubmitting, setIsSubmitting] = useState(false); - const form = useForm({ - resolver: zodResolver(formSchema), - defaultValues: { - comment: "", - }, - }); + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + comment: "", + }, + }); - const deletePolicy = useAction(deletePolicyAction, { - onSuccess: () => { - toast.info("Policy deleted! Redirecting to policies list..."); - onClose(); - router.push(`/${policy.organizationId}/policies/all`); - }, - onError: () => { - toast.error("Failed to delete policy."); - }, - }); + const deletePolicy = useAction(deletePolicyAction, { + onSuccess: () => { + toast.info("Policy deleted! Redirecting to policies list..."); + onClose(); + router.push(`/${policy.organizationId}/policies/all`); + }, + onError: () => { + toast.error("Failed to delete policy."); + }, + }); - const handleSubmit = async (values: FormValues) => { - setIsSubmitting(true); - deletePolicy.execute({ - id: policy.id, - entityId: policy.id, - }); - }; + const handleSubmit = async (values: FormValues) => { + setIsSubmitting(true); + deletePolicy.execute({ + id: policy.id, + entityId: policy.id, + }); + }; - return ( - !open && onClose()}> - - - Delete Policy - - Are you sure you want to delete this policy? This action - cannot be undone. - - - - - - - - - - - - - ); + return ( + !open && onClose()}> + + + Delete Policy + + Are you sure you want to delete this policy? This action cannot be + undone. + + +
+ + + + + +
+ +
+
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyOverview.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyOverview.tsx index a424fc21d4..c7ec8e249f 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyOverview.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyOverview.tsx @@ -7,23 +7,29 @@ import type { Member, Policy, User } from "@comp/db/types"; import { Control } from "@comp/db/types"; import { Alert, AlertDescription, AlertTitle } from "@comp/ui/alert"; import { Button } from "@comp/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@comp/ui/card"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@comp/ui/card"; import { Icons } from "@comp/ui/icons"; import { format } from "date-fns"; import { - ArchiveIcon, - ArchiveRestoreIcon, - MoreVertical, - PencilIcon, - ShieldCheck, - ShieldX, - Trash2, + ArchiveIcon, + ArchiveRestoreIcon, + MoreVertical, + PencilIcon, + ShieldCheck, + ShieldX, + Trash2, } from "lucide-react"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, } from "@comp/ui/dropdown-menu"; import { useAction } from "next-safe-action/hooks"; import { useQueryState } from "nuqs"; @@ -38,282 +44,264 @@ import { UpdatePolicyOverview } from "./UpdatePolicyOverview"; import { deletePolicyAction } from "@/actions/policies/delete-policy"; export function PolicyOverview({ - policy, - assignees, - mappedControls, - allControls, - isPendingApproval, + policy, + assignees, + mappedControls, + allControls, + isPendingApproval, }: { - policy: (Policy & { approver: (Member & { user: User }) | null }) | null; - assignees: (Member & { user: User })[]; - mappedControls: Control[]; - allControls: Control[]; - isPendingApproval: boolean; + policy: (Policy & { approver: (Member & { user: User }) | null }) | null; + assignees: (Member & { user: User })[]; + mappedControls: Control[]; + allControls: Control[]; + isPendingApproval: boolean; }) { - const { data: activeMember } = authClient.useActiveMember(); - const [, setOpen] = useQueryState("policy-overview-sheet"); - const [, setArchiveOpen] = useQueryState("archive-policy-sheet"); - const canCurrentUserApprove = policy?.approverId === activeMember?.id; + const { data: activeMember } = authClient.useActiveMember(); + const [, setOpen] = useQueryState("policy-overview-sheet"); + const [, setArchiveOpen] = useQueryState("archive-policy-sheet"); + const canCurrentUserApprove = policy?.approverId === activeMember?.id; - const denyPolicyChanges = useAction(denyRequestedPolicyChangesAction, { - onSuccess: () => { - toast.info("Policy changes denied!"); - // Force a complete page reload instead of just a refresh - window.location.reload(); - }, - onError: () => { - toast.error("Failed to deny policy changes."); - }, - }); + const denyPolicyChanges = useAction(denyRequestedPolicyChangesAction, { + onSuccess: () => { + toast.info("Policy changes denied!"); + // Force a complete page reload instead of just a refresh + window.location.reload(); + }, + onError: () => { + toast.error("Failed to deny policy changes."); + }, + }); - const acceptPolicyChanges = useAction(acceptRequestedPolicyChangesAction, { - onSuccess: () => { - toast.success("Policy changes accepted and published!"); - // Force a complete page reload instead of just a refresh - window.location.reload(); - }, - onError: () => { - toast.error("Failed to accept policy changes."); - }, - }); + const acceptPolicyChanges = useAction(acceptRequestedPolicyChangesAction, { + onSuccess: () => { + toast.success("Policy changes accepted and published!"); + // Force a complete page reload instead of just a refresh + window.location.reload(); + }, + onError: () => { + toast.error("Failed to accept policy changes."); + }, + }); - // Dialog state for approval/denial actions - const [approveDialogOpen, setApproveDialogOpen] = useState(false); - const [denyDialogOpen, setDenyDialogOpen] = useState(false); - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + // Dialog state for approval/denial actions + const [approveDialogOpen, setApproveDialogOpen] = useState(false); + const [denyDialogOpen, setDenyDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - // Dropdown menu state - const [dropdownOpen, setDropdownOpen] = useState(false); + // Dropdown menu state + const [dropdownOpen, setDropdownOpen] = useState(false); - // Handle approve with optional comment - const handleApprove = (comment?: string) => { - if (policy?.id && policy.approverId) { - acceptPolicyChanges.execute({ - id: policy.id, - approverId: policy.approverId, - comment, - entityId: policy.id, - }); - } - }; + // Handle approve with optional comment + const handleApprove = (comment?: string) => { + if (policy?.id && policy.approverId) { + acceptPolicyChanges.execute({ + id: policy.id, + approverId: policy.approverId, + comment, + entityId: policy.id, + }); + } + }; - // Handle deny with optional comment - const handleDeny = (comment?: string) => { - if (policy?.id && policy.approverId) { - denyPolicyChanges.execute({ - id: policy.id, - approverId: policy.approverId, - comment, - entityId: policy.id, - }); - } - }; + // Handle deny with optional comment + const handleDeny = (comment?: string) => { + if (policy?.id && policy.approverId) { + denyPolicyChanges.execute({ + id: policy.id, + approverId: policy.approverId, + comment, + entityId: policy.id, + }); + } + }; - if (!policy) { - return null; - } + if (!policy) { + return null; + } - return ( -
- {isPendingApproval && ( - - - - {canCurrentUserApprove - ? "Action Required by You" - : "Pending Approval"} - - -
- This policy is awaiting approval from{" "} - - {policy.approverId === activeMember?.id - ? "you" - : `${policy?.approver?.user?.name} (${policy?.approver?.user?.email})`} - - . -
- {canCurrentUserApprove && - " Please review the details and either approve or reject the changes."} - {!canCurrentUserApprove && - " All fields are disabled until the policy is actioned."} - {isPendingApproval && - policy.approverId && - canCurrentUserApprove && ( -
- - -
- )} -
-
- )} - {policy?.isArchived && ( - -
- -
- {"This policy is archived"} -
-
- - {policy?.isArchived && ( - <> - {"Archived on"}{" "} - {format( - new Date(policy?.updatedAt ?? new Date()), - "PPP", - )} - - )} - - -
- )} + return ( +
+ {isPendingApproval && ( + + + + {canCurrentUserApprove + ? "Action Required by You" + : "Pending Approval"} + + +
+ This policy is awaiting approval from{" "} + + {policy.approverId === activeMember?.id + ? "you" + : `${policy?.approver?.user?.name} (${policy?.approver?.user?.email})`} + + . +
+ {canCurrentUserApprove && + " Please review the details and either approve or reject the changes."} + {!canCurrentUserApprove && + " All fields are disabled until the policy is actioned."} + {isPendingApproval && + policy.approverId && + canCurrentUserApprove && ( +
+ + +
+ )} +
+
+ )} + {policy?.isArchived && ( + +
+ +
{"This policy is archived"}
+
+ + {policy?.isArchived && ( + <> + {"Archived on"}{" "} + {format(new Date(policy?.updatedAt ?? new Date()), "PPP")} + + )} + + +
+ )} - - - -
-
- - {policy?.name} -
- - - - - - { - setDropdownOpen(false); - setOpen("true"); - }} - disabled={isPendingApproval} - > - - {"Edit policy"} - - { - setDropdownOpen(false); - setArchiveOpen("true"); - }} - disabled={isPendingApproval} - > - {policy?.isArchived ? ( - - ) : ( - - )} - {policy?.isArchived - ? "Restore policy" - : "Archive policy"} - - { - setDropdownOpen(false); - setDeleteDialogOpen(true); - }} - disabled={isPendingApproval} - className="text-destructive focus:text-destructive" - > - - Delete - - - -
-
- - {policy?.description} - -
- - {policy && ( - - )} - -
+ + + +
+
+ + {policy?.name} +
+ + + + + + { + setDropdownOpen(false); + setOpen("true"); + }} + disabled={isPendingApproval} + > + + {"Edit policy"} + + { + setDropdownOpen(false); + setArchiveOpen("true"); + }} + disabled={isPendingApproval} + > + {policy?.isArchived ? ( + + ) : ( + + )} + {policy?.isArchived ? "Restore policy" : "Archive policy"} + + { + setDropdownOpen(false); + setDeleteDialogOpen(true); + }} + disabled={isPendingApproval} + className="text-destructive focus:text-destructive" + > + + Delete + + + +
+
+ {policy?.description} +
+ + {policy && ( + + )} + +
- + - {policy && ( - <> - - + {policy && ( + <> + + - {/* Approval Dialog */} - setApproveDialogOpen(false)} - onConfirm={handleApprove} - title="Approve Policy Changes" - description="Are you sure you want to approve these policy changes? You can optionally add a comment that will be visible in the policy history." - confirmText="Approve" - confirmIcon={} - /> + {/* Approval Dialog */} + setApproveDialogOpen(false)} + onConfirm={handleApprove} + title="Approve Policy Changes" + description="Are you sure you want to approve these policy changes? You can optionally add a comment that will be visible in the policy history." + confirmText="Approve" + confirmIcon={} + /> - {/* Denial Dialog */} - setDenyDialogOpen(false)} - onConfirm={handleDeny} - title="Deny Policy Changes" - description="Are you sure you want to deny these policy changes? You can optionally add a comment explaining your decision that will be visible in the policy history." - confirmText="Deny" - confirmIcon={} - confirmVariant="destructive" - /> + {/* Denial Dialog */} + setDenyDialogOpen(false)} + onConfirm={handleDeny} + title="Deny Policy Changes" + description="Are you sure you want to deny these policy changes? You can optionally add a comment explaining your decision that will be visible in the policy history." + confirmText="Deny" + confirmIcon={} + confirmVariant="destructive" + /> - {/* Delete Dialog */} - setDeleteDialogOpen(false)} - policy={policy} - /> - - )} -
- ); + {/* Delete Dialog */} + setDeleteDialogOpen(false)} + policy={policy} + /> + + )} +
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyOverviewSheet.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyOverviewSheet.tsx index 3fc948518a..0b541d6ed9 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyOverviewSheet.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyOverviewSheet.tsx @@ -7,67 +7,59 @@ import { Drawer, DrawerContent, DrawerTitle } from "@comp/ui/drawer"; import { useMediaQuery } from "@comp/ui/hooks"; import { ScrollArea } from "@comp/ui/scroll-area"; import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, } from "@comp/ui/sheet"; import { X } from "lucide-react"; import { useQueryState } from "nuqs"; -export function PolicyOverviewSheet({ - policy, -}: { - policy: Policy; -}) { - const isDesktop = useMediaQuery("(min-width: 768px)"); - const [open, setOpen] = useQueryState("policy-overview-sheet"); - const isOpen = Boolean(open); +export function PolicyOverviewSheet({ policy }: { policy: Policy }) { + const isDesktop = useMediaQuery("(min-width: 768px)"); + const [open, setOpen] = useQueryState("policy-overview-sheet"); + const isOpen = Boolean(open); - const handleOpenChange = (open: boolean) => { - setOpen(open ? "true" : null); - }; + const handleOpenChange = (open: boolean) => { + setOpen(open ? "true" : null); + }; - if (isDesktop) { - return ( - - - -
- - {"Update Policy"} - - -
{" "} - - {"Update policy details, content and metadata."} - -
+ if (isDesktop) { + return ( + + + +
+ {"Update Policy"} + +
{" "} + + {"Update policy details, content and metadata."} + +
- - - -
-
- ); - } + + + +
+
+ ); + } - return ( - - - - - - - ); + return ( + + + + + + + ); } diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyPage.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyPage.tsx index 31f72cce3e..dffd246d0b 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyPage.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/PolicyPage.tsx @@ -1,8 +1,8 @@ import { Control, Member, Policy, User } from "@comp/db/types"; import { JSONContent } from "novel"; import { - Comments, - CommentWithAuthor, + Comments, + CommentWithAuthor, } from "../../../../../../components/comments/Comments"; import { AuditLogWithRelations } from "../data"; import { PolicyPageEditor } from "../editor/components/PolicyDetails"; @@ -10,48 +10,42 @@ import { PolicyOverview } from "./PolicyOverview"; import { RecentAuditLogs } from "./RecentAuditLogs"; export default function PolicyPage({ - policy, - assignees, - mappedControls, - allControls, - isPendingApproval, - policyId, - logs, - comments, + policy, + assignees, + mappedControls, + allControls, + isPendingApproval, + policyId, + logs, + comments, }: { - policy: (Policy & { approver: (Member & { user: User }) | null }) | null; - assignees: (Member & { user: User })[]; - mappedControls: Control[]; - allControls: Control[]; - isPendingApproval: boolean; - policyId: string; - logs: AuditLogWithRelations[]; - comments: CommentWithAuthor[]; + policy: (Policy & { approver: (Member & { user: User }) | null }) | null; + assignees: (Member & { user: User })[]; + mappedControls: Control[]; + allControls: Control[]; + isPendingApproval: boolean; + policyId: string; + logs: AuditLogWithRelations[]; + comments: CommentWithAuthor[]; }) { - return ( - <> - - + return ( + <> + + - + - - - ); + + + ); } diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/RecentAuditLogs.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/RecentAuditLogs.tsx index cd79a8f822..cabc76c8da 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/RecentAuditLogs.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/RecentAuditLogs.tsx @@ -1,259 +1,241 @@ import { AuditLog, AuditLogEntityType } from "@comp/db/types"; import { AuditLogWithRelations } from "../data"; import { - Card, - CardContent, - CardHeader, - CardTitle, - CardDescription, + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, } from "@comp/ui/card"; import { Badge } from "@comp/ui/badge"; import { Avatar, AvatarFallback, AvatarImage } from "@comp/ui/avatar"; import { - CalendarIcon, - ClockIcon, - UserIcon, - ActivityIcon, - FileIcon, - FileTextIcon, - ShieldIcon, + CalendarIcon, + ClockIcon, + UserIcon, + ActivityIcon, + FileIcon, + FileTextIcon, + ShieldIcon, } from "lucide-react"; import { cn } from "@comp/ui/cn"; import { format } from "date-fns"; import { ScrollArea } from "@comp/ui/scroll-area"; type LogActionType = - | "create" - | "update" - | "delete" - | "approve" - | "reject" - | "review"; + | "create" + | "update" + | "delete" + | "approve" + | "reject" + | "review"; // Using the imported AuditLogWithRelations type from data/index.ts interface LogData { - action?: LogActionType; - details?: Record; - changes?: Record; + action?: LogActionType; + details?: Record; + changes?: Record; } const getActionColor = (action: LogActionType | string) => { - switch (action) { - case "create": - return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300"; - case "update": - return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300"; - case "delete": - return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300"; - case "approve": - return "bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-300"; - case "reject": - return "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300"; - case "review": - return "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300"; - default: - return "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300"; - } + switch (action) { + case "create": + return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300"; + case "update": + return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300"; + case "delete": + return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300"; + case "approve": + return "bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-300"; + case "reject": + return "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300"; + case "review": + return "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300"; + default: + return "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300"; + } }; const getInitials = (name = "") => { - if (!name) return "U"; - return name - .split(" ") - .map((part) => part[0]) - .join("") - .toUpperCase() - .slice(0, 2); + if (!name) return "U"; + return name + .split(" ") + .map((part) => part[0]) + .join("") + .toUpperCase() + .slice(0, 2); }; const getEntityTypeIcon = ( - entityType: AuditLogEntityType | null | undefined, + entityType: AuditLogEntityType | null | undefined, ) => { - switch (entityType) { - case AuditLogEntityType.policy: - return ; - case AuditLogEntityType.control: - return ; - default: - return ; - } + switch (entityType) { + case AuditLogEntityType.policy: + return ; + case AuditLogEntityType.control: + return ; + default: + return ; + } }; // Parse the data field to extract relevant information const parseLogData = (log: AuditLog): LogData => { - try { - if (typeof log.data === "object" && log.data !== null) { - const data = log.data as Record; - return { - action: data.action as LogActionType, - details: data.details, - changes: data.changes, - }; - } - } catch (e) { - console.error("Error parsing audit log data", e); - } - - return {}; + try { + if (typeof log.data === "object" && log.data !== null) { + const data = log.data as Record; + return { + action: data.action as LogActionType, + details: data.details, + changes: data.changes, + }; + } + } catch (e) { + console.error("Error parsing audit log data", e); + } + + return {}; }; const getUserInfo = (log: AuditLogWithRelations) => { - // We only have the direct user relation in our updated type - if (log.user) { - return { - name: log.user.name, - email: log.user.email, - avatarUrl: log.user.image || undefined, - }; - } - - // Default fallback - return { - name: undefined, - email: undefined, - avatarUrl: undefined, - }; + // We only have the direct user relation in our updated type + if (log.user) { + return { + name: log.user.name, + email: log.user.email, + avatarUrl: log.user.image || undefined, + }; + } + + // Default fallback + return { + name: undefined, + email: undefined, + avatarUrl: undefined, + }; }; const LogItem = ({ log }: { log: AuditLogWithRelations }) => { - const logData = parseLogData(log); - const userInfo = getUserInfo(log); - const actionType = logData.action || "update"; - - return ( - - -
- - - - {getInitials(userInfo.name)} - - - -
-
-
- {userInfo.name || - `User ${log.userId.substring(0, 6)}`} -
- - {actionType.charAt(0).toUpperCase() + - actionType.slice(1)} - -
- - - {log.description || "No description available"} - - - {logData.changes && - Object.keys(logData.changes).length > 0 && ( -
-
- Changes: -
-
    - {Object.entries(logData.changes).map( - ([ - field, - { previous, current }, - ]) => ( -
  • - - {field}: - {" "} - - {previous?.toString() || - "(empty)"} - {" "} - - β†’{" "} - {current?.toString() || - "(empty)"} - -
  • - ), - )} -
-
- )} - -
-
- - {format(log.timestamp, "MMM d, yyyy")} -
-
- - {format(log.timestamp, "h:mm a")} -
- {log.entityType && ( -
- {getEntityTypeIcon(log.entityType)} - {log.entityType} -
- )} - {log.entityId && ( -
- - ID: {log.entityId.substring(0, 8)} -
- )} -
-
-
-
-
- ); + const logData = parseLogData(log); + const userInfo = getUserInfo(log); + const actionType = logData.action || "update"; + + return ( + + +
+ + + {getInitials(userInfo.name)} + + +
+
+
+ {userInfo.name || `User ${log.userId.substring(0, 6)}`} +
+ + {actionType.charAt(0).toUpperCase() + actionType.slice(1)} + +
+ + + {log.description || "No description available"} + + + {logData.changes && Object.keys(logData.changes).length > 0 && ( +
+
Changes:
+
    + {Object.entries(logData.changes).map( + ([field, { previous, current }]) => ( +
  • + {field}:{" "} + + {previous?.toString() || "(empty)"} + {" "} + + β†’ {current?.toString() || "(empty)"} + +
  • + ), + )} +
+
+ )} + +
+
+ + {format(log.timestamp, "MMM d, yyyy")} +
+
+ + {format(log.timestamp, "h:mm a")} +
+ {log.entityType && ( +
+ {getEntityTypeIcon(log.entityType)} + {log.entityType} +
+ )} + {log.entityId && ( +
+ + ID: {log.entityId.substring(0, 8)} +
+ )} +
+
+
+
+
+ ); }; export const RecentAuditLogs = ({ - logs, -}: { logs: AuditLogWithRelations[] }) => { - return ( - - -
- - Recent Activity - -
-
- - - - {logs.length > 0 ? ( -
-
- {logs.map((log) => ( - - ))} -
-
- ) : ( -
- -

- No recent activity -

-

- Activity will appear here when changes are made to - this policy -

-
- )} -
-
-
- ); + logs, +}: { + logs: AuditLogWithRelations[]; +}) => { + return ( + + +
+ Recent Activity +
+
+ + + + {logs.length > 0 ? ( +
+
+ {logs.map((log) => ( + + ))} +
+
+ ) : ( +
+ +

No recent activity

+

+ Activity will appear here when changes are made to this policy +

+
+ )} +
+
+
+ ); }; diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/SubmitApprovalDialog.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/SubmitApprovalDialog.tsx index 088ee62d0f..b0ad3d5353 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/SubmitApprovalDialog.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/SubmitApprovalDialog.tsx @@ -4,67 +4,62 @@ import { SelectAssignee } from "@/components/SelectAssignee"; import { Member, User } from "@comp/db/types"; import { Button } from "@comp/ui/button"; import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, } from "@comp/ui/dialog"; import { Loader2 } from "lucide-react"; interface SubmitApprovalDialogProps { - isOpen: boolean; - onOpenChange: (isOpen: boolean) => void; - assignees: (Member & { user: User })[]; - selectedApproverId: string | null; - onSelectedApproverIdChange: (id: string | null) => void; - onConfirm: () => void; - isSubmitting: boolean; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + assignees: (Member & { user: User })[]; + selectedApproverId: string | null; + onSelectedApproverIdChange: (id: string | null) => void; + onConfirm: () => void; + isSubmitting: boolean; } export const SubmitApprovalDialog = ({ - isOpen, - onOpenChange, - assignees, - selectedApproverId, - onSelectedApproverIdChange, - onConfirm, - isSubmitting, + isOpen, + onOpenChange, + assignees, + selectedApproverId, + onSelectedApproverIdChange, + onConfirm, + isSubmitting, }: SubmitApprovalDialogProps) => { - return ( - - - - Submit for Approval - - Please select an approver for this policy. - - - - - - - - - - ); + return ( + + + + Submit for Approval + + Please select an approver for this policy. + + + + + + + + + + ); }; diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/UpdatePolicyOverview.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/UpdatePolicyOverview.tsx index b22456bee6..8edcbd5f74 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/UpdatePolicyOverview.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/UpdatePolicyOverview.tsx @@ -5,23 +5,23 @@ import { updatePolicyFormAction } from "@/actions/policies/update-policy-form-ac import { SelectAssignee } from "@/components/SelectAssignee"; import { StatusIndicator } from "@/components/status-indicator"; import { - Departments, - Frequency, - Member, - type Policy, - PolicyStatus, - User, + Departments, + Frequency, + Member, + type Policy, + PolicyStatus, + User, } from "@comp/db/types"; import { Button } from "@comp/ui/button"; import { Calendar } from "@comp/ui/calendar"; import { cn } from "@comp/ui/cn"; import { Popover, PopoverContent, PopoverTrigger } from "@comp/ui/popover"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, } from "@comp/ui/select"; import { Switch } from "@comp/ui/switch"; import { format } from "date-fns"; @@ -33,494 +33,444 @@ import { SubmitApprovalDialog } from "./SubmitApprovalDialog"; import { useRouter } from "next/navigation"; interface UpdatePolicyOverviewProps { - policy: Policy & { - approver: (Member & { user: User }) | null; - }; - assignees: (Member & { user: User })[]; - isPendingApproval: boolean; + policy: Policy & { + approver: (Member & { user: User }) | null; + }; + assignees: (Member & { user: User })[]; + isPendingApproval: boolean; } export function UpdatePolicyOverview({ - policy, - assignees, - isPendingApproval, + policy, + assignees, + isPendingApproval, }: UpdatePolicyOverviewProps) { - // Dialog state only - no form state - const [isApprovalDialogOpen, setIsApprovalDialogOpen] = useState(false); - const [selectedApproverId, setSelectedApproverId] = useState( - null, - ); - const router = useRouter(); + // Dialog state only - no form state + const [isApprovalDialogOpen, setIsApprovalDialogOpen] = useState(false); + const [selectedApproverId, setSelectedApproverId] = useState( + null, + ); + const router = useRouter(); - // Track selected status - const [selectedStatus, setSelectedStatus] = useState( - policy.status, - ); + // Track selected status + const [selectedStatus, setSelectedStatus] = useState( + policy.status, + ); - // Date picker state - UI only - const [isDatePickerOpen, setIsDatePickerOpen] = useState(false); - const [tempDate, setTempDate] = useState(undefined); - const popoverRef = useRef(null); + // Date picker state - UI only + const [isDatePickerOpen, setIsDatePickerOpen] = useState(false); + const [tempDate, setTempDate] = useState(undefined); + const popoverRef = useRef(null); - // Loading state - const [isSubmitting, setIsSubmitting] = useState(false); + // Loading state + const [isSubmitting, setIsSubmitting] = useState(false); - // Track form interactions to determine button text - const [formInteracted, setFormInteracted] = useState(false); + // Track form interactions to determine button text + const [formInteracted, setFormInteracted] = useState(false); - const fieldsDisabled = isPendingApproval; + const fieldsDisabled = isPendingApproval; - const updatePolicyForm = useAction(updatePolicyFormAction, { - onSuccess: () => { - toast.success("Policy updated successfully"); - setIsSubmitting(false); - setFormInteracted(false); // Reset form interaction state after successful update - router.refresh(); - }, - onError: () => { - toast.error("Failed to update policy"); - setIsSubmitting(false); - }, - }); + const updatePolicyForm = useAction(updatePolicyFormAction, { + onSuccess: () => { + toast.success("Policy updated successfully"); + setIsSubmitting(false); + setFormInteracted(false); // Reset form interaction state after successful update + router.refresh(); + }, + onError: () => { + toast.error("Failed to update policy"); + setIsSubmitting(false); + }, + }); - const submitForApproval = useAction(submitPolicyForApprovalAction, { - onSuccess: () => { - toast.success("Policy submitted for approval successfully!"); - setIsSubmitting(false); - setIsApprovalDialogOpen(false); - setFormInteracted(false); // Reset form interaction state after successful submission - setSelectedStatus("needs_review"); - router.refresh(); - }, - onError: () => { - toast.error("Failed to submit policy for approval."); - setIsSubmitting(false); - }, - }); + const submitForApproval = useAction(submitPolicyForApprovalAction, { + onSuccess: () => { + toast.success("Policy submitted for approval successfully!"); + setIsSubmitting(false); + setIsApprovalDialogOpen(false); + setFormInteracted(false); // Reset form interaction state after successful submission + setSelectedStatus("needs_review"); + router.refresh(); + }, + onError: () => { + toast.error("Failed to submit policy for approval."); + setIsSubmitting(false); + }, + }); - // Function to handle date confirmation - const handleDateConfirm = (date: Date | undefined) => { - setTempDate(date); - setIsDatePickerOpen(false); - }; + // Function to handle date confirmation + const handleDateConfirm = (date: Date | undefined) => { + setTempDate(date); + setIsDatePickerOpen(false); + }; - // Function to handle form field changes - const handleFormChange = () => { - setFormInteracted(true); - }; + // Function to handle form field changes + const handleFormChange = () => { + setFormInteracted(true); + }; - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - setIsSubmitting(true); + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setIsSubmitting(true); - // Get form data directly from the form element - const formData = new FormData(e.currentTarget); - const status = formData.get("status") as PolicyStatus; - const assigneeId = (formData.get("assigneeId") as string) || null; - const department = formData.get("department") as Departments; - const reviewFrequency = formData.get("review_frequency") as Frequency; - const isRequiredToSign = - formData.get("isRequiredToSign") === "on" - ? "required" - : "not_required"; + // Get form data directly from the form element + const formData = new FormData(e.currentTarget); + const status = formData.get("status") as PolicyStatus; + const assigneeId = (formData.get("assigneeId") as string) || null; + const department = formData.get("department") as Departments; + const reviewFrequency = formData.get("review_frequency") as Frequency; + const isRequiredToSign = + formData.get("isRequiredToSign") === "on" ? "required" : "not_required"; - // Get review date from the form or use the existing one - const reviewDate = - tempDate || - (policy.reviewDate ? new Date(policy.reviewDate) : new Date()); + // Get review date from the form or use the existing one + const reviewDate = + tempDate || + (policy.reviewDate ? new Date(policy.reviewDate) : new Date()); - // Check if the policy is published and if there are changes - const isPublishedWithChanges = - policy.status === "published" && - (status !== policy.status || - assigneeId !== policy.assigneeId || - department !== policy.department || - reviewFrequency !== policy.frequency || - (policy.isRequiredToSign ? "required" : "not_required") !== - isRequiredToSign || - (policy.reviewDate - ? new Date(policy.reviewDate).toDateString() - : "") !== reviewDate.toDateString()); + // Check if the policy is published and if there are changes + const isPublishedWithChanges = + policy.status === "published" && + (status !== policy.status || + assigneeId !== policy.assigneeId || + department !== policy.department || + reviewFrequency !== policy.frequency || + (policy.isRequiredToSign ? "required" : "not_required") !== + isRequiredToSign || + (policy.reviewDate + ? new Date(policy.reviewDate).toDateString() + : "") !== reviewDate.toDateString()); - // If policy is draft and being published OR policy is published and has changes - if ( - (policy.status === "draft" && status === "published") || - isPublishedWithChanges - ) { - setIsApprovalDialogOpen(true); - setIsSubmitting(false); - } else { - updatePolicyForm.execute({ - id: policy.id, - status, - assigneeId, - department, - review_frequency: reviewFrequency, - review_date: reviewDate, - isRequiredToSign, - approverId: null, - entityId: policy.id, - }); - } - }; + // If policy is draft and being published OR policy is published and has changes + if ( + (policy.status === "draft" && status === "published") || + isPublishedWithChanges + ) { + setIsApprovalDialogOpen(true); + setIsSubmitting(false); + } else { + updatePolicyForm.execute({ + id: policy.id, + status, + assigneeId, + department, + review_frequency: reviewFrequency, + review_date: reviewDate, + isRequiredToSign, + approverId: null, + entityId: policy.id, + }); + } + }; - const handleConfirmApproval = () => { - if (!selectedApproverId) { - toast.error("Approver is required."); - return; - } + const handleConfirmApproval = () => { + if (!selectedApproverId) { + toast.error("Approver is required."); + return; + } - // Get form data directly from the DOM - const form = document.getElementById("policy-form") as HTMLFormElement; - const formData = new FormData(form); - const assigneeId = (formData.get("assigneeId") as string) || null; - const department = formData.get("department") as Departments; - const reviewFrequency = formData.get("review_frequency") as Frequency; - const isRequiredToSign = - formData.get("isRequiredToSign") === "on" - ? "required" - : "not_required"; + // Get form data directly from the DOM + const form = document.getElementById("policy-form") as HTMLFormElement; + const formData = new FormData(form); + const assigneeId = (formData.get("assigneeId") as string) || null; + const department = formData.get("department") as Departments; + const reviewFrequency = formData.get("review_frequency") as Frequency; + const isRequiredToSign = + formData.get("isRequiredToSign") === "on" ? "required" : "not_required"; - // Get review date from the form or use the existing one - const reviewDate = - tempDate || - (policy.reviewDate ? new Date(policy.reviewDate) : new Date()); + // Get review date from the form or use the existing one + const reviewDate = + tempDate || + (policy.reviewDate ? new Date(policy.reviewDate) : new Date()); - setIsSubmitting(true); - submitForApproval.execute({ - id: policy.id, - status: PolicyStatus.needs_review, - assigneeId, - department, - review_frequency: reviewFrequency, - review_date: reviewDate, - isRequiredToSign, - approverId: selectedApproverId, - entityId: policy.id, - }); - setSelectedApproverId(null); - }; + setIsSubmitting(true); + submitForApproval.execute({ + id: policy.id, + status: PolicyStatus.needs_review, + assigneeId, + department, + review_frequency: reviewFrequency, + review_date: reviewDate, + isRequiredToSign, + approverId: selectedApproverId, + entityId: policy.id, + }); + setSelectedApproverId(null); + }; - // Check if form has been modified to determine button state - const hasFormChanges = formInteracted; + // Check if form has been modified to determine button state + const hasFormChanges = formInteracted; - // Determine button text based on status and form interaction - let buttonText = "Save"; - if ( - policy.status === "draft" || - (policy.status === "published" && hasFormChanges) - ) { - buttonText = "Submit for Approval"; - } + // Determine button text based on status and form interaction + let buttonText = "Save"; + if ( + policy.status === "draft" || + (policy.status === "published" && hasFormChanges) + ) { + buttonText = "Submit for Approval"; + } - return ( - <> -
-
- {/* Status Field */} -
- - {/* Hidden input for form submission */} - - -
+ return ( + <> + +
+ {/* Status Field */} +
+ + {/* Hidden input for form submission */} + + +
- {/* Review Frequency Field */} -
- - -
+ {/* Review Frequency Field */} +
+ + +
- {/* Department Field */} -
- - -
+ {/* Department Field */} +
+ + +
- {/* Assignee Field */} -
- - {/* Hidden input for form submission */} - - { - // Update the hidden input value - const input = document.getElementById( - "assigneeId", - ) as HTMLInputElement; - if (input) input.value = id || ""; - handleFormChange(); - }} - assigneeId={policy.assigneeId || ""} - disabled={fieldsDisabled} - withTitle={false} - /> -
+ {/* Assignee Field */} +
+ + {/* Hidden input for form submission */} + + { + // Update the hidden input value + const input = document.getElementById( + "assigneeId", + ) as HTMLInputElement; + if (input) input.value = id || ""; + handleFormChange(); + }} + assigneeId={policy.assigneeId || ""} + disabled={fieldsDisabled} + withTitle={false} + /> +
- {/* Review Date Field */} -
- - { - setIsDatePickerOpen(open); - if (!open) { - setTempDate(undefined); - } - }} - > - -
- -
-
- -
- { - setTempDate(date); - handleFormChange(); - }} - disabled={(date) => date <= new Date()} - initialFocus - /> -
- - -
-
-
-
- {/* Hidden input to store the date value */} - -
+ {/* Review Date Field */} +
+ + { + setIsDatePickerOpen(open); + if (!open) { + setTempDate(undefined); + } + }} + > + +
+ +
+
+ +
+ { + setTempDate(date); + handleFormChange(); + }} + disabled={(date) => date <= new Date()} + initialFocus + /> +
+ + +
+
+
+
+ {/* Hidden input to store the date value */} + +
- {/* Required to Sign Field */} -
- -
- - - {policy.isRequiredToSign - ? "Required" - : "Not Required"} - -
-
-
+ {/* Required to Sign Field */} +
+ +
+ + + {policy.isRequiredToSign ? "Required" : "Not Required"} + +
+
+
-
- {!isPendingApproval && ( - - )} -
-
+
+ {!isPendingApproval && ( + + )} +
+ - - - ); + + + ); } diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/data/index.ts b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/data/index.ts index c513eee956..871f593297 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/data/index.ts +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/data/index.ts @@ -4,199 +4,197 @@ import { auth } from "@/utils/auth"; import { headers } from "next/headers"; import { CommentWithAuthor } from "@/components/comments/Comments"; import { - AttachmentEntityType, - AuditLogEntityType, - CommentEntityType, - AuditLog, - User, - Member, - Organization, + AttachmentEntityType, + AuditLogEntityType, + CommentEntityType, + AuditLog, + User, + Member, + Organization, } from "@comp/db/types"; import { db } from "@comp/db"; // Define the type for AuditLog with its relations export type AuditLogWithRelations = AuditLog & { - user: User | null; - member: Member | null; - organization: Organization; + user: User | null; + member: Member | null; + organization: Organization; }; export const getLogsForPolicy = async ( - policyId: string, + policyId: string, ): Promise => { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - const organizationId = session?.session.activeOrganizationId; - - if (!organizationId) { - return []; - } - - const logs = await db.auditLog.findMany({ - where: { - organizationId, - entityType: AuditLogEntityType.policy, - entityId: policyId, - }, - include: { - user: true, - member: true, - organization: true, - }, - orderBy: { - timestamp: "desc", - }, - take: 3, - }); - - return logs; + const session = await auth.api.getSession({ + headers: await headers(), + }); + + const organizationId = session?.session.activeOrganizationId; + + if (!organizationId) { + return []; + } + + const logs = await db.auditLog.findMany({ + where: { + organizationId, + entityType: AuditLogEntityType.policy, + entityId: policyId, + }, + include: { + user: true, + member: true, + organization: true, + }, + orderBy: { + timestamp: "desc", + }, + take: 3, + }); + + return logs; }; export const getPolicyControlMappingInfo = async (policyId: string) => { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - const organizationId = session?.session.activeOrganizationId; - - if (!organizationId) { - return { mappedControls: [], allControls: [] }; - } - - const mappedControls = await db.control.findMany({ - where: { - organizationId, - policies: { - some: { - id: policyId, - }, - }, - }, - }); - - const allControls = await db.control.findMany({ - where: { - organizationId, - }, - }); - - return { - mappedControls: mappedControls || [], - allControls: allControls || [], - }; + const session = await auth.api.getSession({ + headers: await headers(), + }); + + const organizationId = session?.session.activeOrganizationId; + + if (!organizationId) { + return { mappedControls: [], allControls: [] }; + } + + const mappedControls = await db.control.findMany({ + where: { + organizationId, + policies: { + some: { + id: policyId, + }, + }, + }, + }); + + const allControls = await db.control.findMany({ + where: { + organizationId, + }, + }); + + return { + mappedControls: mappedControls || [], + allControls: allControls || [], + }; }; export const getPolicy = async (policyId: string) => { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - const organizationId = session?.session.activeOrganizationId; - - if (!organizationId) { - return null; - } - - const policy = await db.policy.findUnique({ - where: { id: policyId, organizationId }, - include: { - approver: { - include: { - user: true, - }, - }, - assignee: { - include: { - user: true, - }, - }, - }, - }); - - if (!policy) { - return null; - } - - return policy; + const session = await auth.api.getSession({ + headers: await headers(), + }); + + const organizationId = session?.session.activeOrganizationId; + + if (!organizationId) { + return null; + } + + const policy = await db.policy.findUnique({ + where: { id: policyId, organizationId }, + include: { + approver: { + include: { + user: true, + }, + }, + assignee: { + include: { + user: true, + }, + }, + }, + }); + + if (!policy) { + return null; + } + + return policy; }; export const getAssignees = async () => { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - const organizationId = session?.session.activeOrganizationId; - - if (!organizationId) { - return []; - } - - const assignees = await db.member.findMany({ - where: { - organizationId, - role: { - notIn: ["employee"], - }, - }, - include: { - user: true, - }, - }); - - return assignees; + const session = await auth.api.getSession({ + headers: await headers(), + }); + + const organizationId = session?.session.activeOrganizationId; + + if (!organizationId) { + return []; + } + + const assignees = await db.member.findMany({ + where: { + organizationId, + role: { + notIn: ["employee"], + }, + }, + include: { + user: true, + }, + }); + + return assignees; }; export const getComments = async ( - policyId: string, + policyId: string, ): Promise => { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - const activeOrgId = session?.session.activeOrganizationId; - - if (!activeOrgId) { - console.warn( - "Could not determine active organization ID in getComments", - ); - return []; - } - - const comments = await db.comment.findMany({ - where: { - organizationId: activeOrgId, - entityId: policyId, - entityType: CommentEntityType.policy, - }, - include: { - author: { - include: { - user: true, - }, - }, - }, - orderBy: { - createdAt: "desc", - }, - }); - - const commentsWithAttachments = await Promise.all( - comments.map(async (comment) => { - const attachments = await db.attachment.findMany({ - where: { - organizationId: activeOrgId, - entityId: comment.id, - entityType: AttachmentEntityType.comment, - }, - }); - return { - ...comment, - attachments, - }; - }), - ); - - return commentsWithAttachments; + const session = await auth.api.getSession({ + headers: await headers(), + }); + + const activeOrgId = session?.session.activeOrganizationId; + + if (!activeOrgId) { + console.warn("Could not determine active organization ID in getComments"); + return []; + } + + const comments = await db.comment.findMany({ + where: { + organizationId: activeOrgId, + entityId: policyId, + entityType: CommentEntityType.policy, + }, + include: { + author: { + include: { + user: true, + }, + }, + }, + orderBy: { + createdAt: "desc", + }, + }); + + const commentsWithAttachments = await Promise.all( + comments.map(async (comment) => { + const attachments = await db.attachment.findMany({ + where: { + organizationId: activeOrgId, + entityId: comment.id, + entityType: AttachmentEntityType.comment, + }, + }); + return { + ...comment, + attachments, + }; + }), + ); + + return commentsWithAttachments; }; diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/actions/get-policy-details.ts b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/actions/get-policy-details.ts index e4bb09ce5a..a7b7b55072 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/actions/get-policy-details.ts +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/actions/get-policy-details.ts @@ -7,54 +7,54 @@ import { headers } from "next/headers"; import { appErrors, policyDetailsInputSchema } from "../types"; export const getPolicyDetails = authActionClient - .schema(policyDetailsInputSchema) - .metadata({ - name: "get-policy-details", - track: { - event: "get-policy-details", - channel: "server", - }, - }) - .action(async ({ parsedInput }) => { - const { policyId } = parsedInput; - - const session = await auth.api.getSession({ - headers: await headers(), - }); - - const organizationId = session?.session.activeOrganizationId; - - if (!organizationId) { - return { - success: false, - error: appErrors.UNAUTHORIZED.message, - }; - } - - try { - const policy = await db.policy.findUnique({ - where: { - id: policyId, - organizationId, - }, - }); - - if (!policy) { - return { - success: false, - error: appErrors.NOT_FOUND.message, - }; - } - - return { - success: true, - data: policy, - }; - } catch (error) { - console.error("Error fetching policy details:", error); - return { - success: false, - error: appErrors.UNEXPECTED_ERROR.message, - }; - } - }); + .schema(policyDetailsInputSchema) + .metadata({ + name: "get-policy-details", + track: { + event: "get-policy-details", + channel: "server", + }, + }) + .action(async ({ parsedInput }) => { + const { policyId } = parsedInput; + + const session = await auth.api.getSession({ + headers: await headers(), + }); + + const organizationId = session?.session.activeOrganizationId; + + if (!organizationId) { + return { + success: false, + error: appErrors.UNAUTHORIZED.message, + }; + } + + try { + const policy = await db.policy.findUnique({ + where: { + id: policyId, + organizationId, + }, + }); + + if (!policy) { + return { + success: false, + error: appErrors.NOT_FOUND.message, + }; + } + + return { + success: true, + data: policy, + }; + } catch (error) { + console.error("Error fetching policy details:", error); + return { + success: false, + error: appErrors.UNEXPECTED_ERROR.message, + }; + } + }); diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/actions/update-policy.ts b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/actions/update-policy.ts index 762ccdf6f0..184b84f2d2 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/actions/update-policy.ts +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/actions/update-policy.ts @@ -9,127 +9,124 @@ import { appErrors, updatePolicySchema } from "../types"; // Helper function to clean the content by removing function references function cleanContent(content: any): any { - if (!content) return content; - - if (Array.isArray(content)) { - return content.map((item) => cleanContent(item)); - } - - if (typeof content === "object") { - const cleaned: any = {}; - for (const [key, value] of Object.entries(content)) { - // Skip function properties - if (typeof value === "function") continue; - cleaned[key] = cleanContent(value); - } - return cleaned; - } - - return content; + if (!content) return content; + + if (Array.isArray(content)) { + return content.map((item) => cleanContent(item)); + } + + if (typeof content === "object") { + const cleaned: any = {}; + for (const [key, value] of Object.entries(content)) { + // Skip function properties + if (typeof value === "function") continue; + cleaned[key] = cleanContent(value); + } + return cleaned; + } + + return content; } export const updatePolicy = authActionClient - .schema(updatePolicySchema) - .metadata({ - name: "update-policy", - track: { - event: "update-policy", - channel: "server", - }, - }) - .action(async ({ parsedInput }): Promise => { - const { policyId, content, status } = parsedInput; - - const session = await auth.api.getSession({ - headers: await headers(), - }); - - const organizationId = session?.session.activeOrganizationId; - - if (!organizationId) { - return { - success: false, - error: appErrors.UNAUTHORIZED.message, - }; - } - - try { - const existingPolicy = await db.policy.findUnique({ - where: { - id: policyId, - organizationId, - }, - }); - - if (!existingPolicy) { - return { - success: false, - error: appErrors.NOT_FOUND.message, - }; - } - - const updateData: Record = {}; - - if (content !== undefined) { - // Clean the content before processing - const cleanedContent = cleanContent(content); - - if ( - typeof cleanedContent === "object" && - cleanedContent !== null - ) { - if ( - "type" in cleanedContent && - cleanedContent.type === "doc" && - "content" in cleanedContent && - Array.isArray(cleanedContent.content) - ) { - updateData.content = cleanedContent.content; - } else if (Array.isArray(cleanedContent)) { - updateData.content = cleanedContent; - } else { - updateData.content = cleanedContent; - } - } else { - updateData.content = cleanedContent; - } - } - - if (status) { - updateData.status = status; - } - - if (Object.keys(updateData).length === 0) { - return { - success: true, - data: { id: policyId, status: existingPolicy.status }, - }; - } - - const updatedPolicy = await db.policy.update({ - where: { - id: policyId, - organizationId, - }, - data: { - ...updateData, - signedBy: [], - }, - select: { - id: true, - status: true, - }, - }); - - return { - success: true, - data: updatedPolicy, - }; - } catch (error) { - console.error("Error updating policy:", error); - return { - success: false, - error: appErrors.UNEXPECTED_ERROR.message, - }; - } - }); + .schema(updatePolicySchema) + .metadata({ + name: "update-policy", + track: { + event: "update-policy", + channel: "server", + }, + }) + .action(async ({ parsedInput }): Promise => { + const { policyId, content, status } = parsedInput; + + const session = await auth.api.getSession({ + headers: await headers(), + }); + + const organizationId = session?.session.activeOrganizationId; + + if (!organizationId) { + return { + success: false, + error: appErrors.UNAUTHORIZED.message, + }; + } + + try { + const existingPolicy = await db.policy.findUnique({ + where: { + id: policyId, + organizationId, + }, + }); + + if (!existingPolicy) { + return { + success: false, + error: appErrors.NOT_FOUND.message, + }; + } + + const updateData: Record = {}; + + if (content !== undefined) { + // Clean the content before processing + const cleanedContent = cleanContent(content); + + if (typeof cleanedContent === "object" && cleanedContent !== null) { + if ( + "type" in cleanedContent && + cleanedContent.type === "doc" && + "content" in cleanedContent && + Array.isArray(cleanedContent.content) + ) { + updateData.content = cleanedContent.content; + } else if (Array.isArray(cleanedContent)) { + updateData.content = cleanedContent; + } else { + updateData.content = cleanedContent; + } + } else { + updateData.content = cleanedContent; + } + } + + if (status) { + updateData.status = status; + } + + if (Object.keys(updateData).length === 0) { + return { + success: true, + data: { id: policyId, status: existingPolicy.status }, + }; + } + + const updatedPolicy = await db.policy.update({ + where: { + id: policyId, + organizationId, + }, + data: { + ...updateData, + signedBy: [], + }, + select: { + id: true, + status: true, + }, + }); + + return { + success: true, + data: updatedPolicy, + }; + } catch (error) { + console.error("Error updating policy:", error); + return { + success: false, + error: appErrors.UNEXPECTED_ERROR.message, + }; + } + }); diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx index 6b62b6ff29..41bedca1c0 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyDetails.tsx @@ -5,42 +5,42 @@ import type { JSONContent } from "@tiptap/react"; import "@comp/ui/editor.css"; import { updatePolicy } from "../actions/update-policy"; interface PolicyDetailsProps { - policyId: string; - policyContent: JSONContent | JSONContent[]; - isPendingApproval: boolean; + policyId: string; + policyContent: JSONContent | JSONContent[]; + isPendingApproval: boolean; } export function PolicyPageEditor({ - policyId, - policyContent, - isPendingApproval, + policyId, + policyContent, + isPendingApproval, }: PolicyDetailsProps) { - const formattedContent = Array.isArray(policyContent) - ? policyContent - : typeof policyContent === "object" && policyContent !== null - ? [policyContent as JSONContent] - : []; + const formattedContent = Array.isArray(policyContent) + ? policyContent + : typeof policyContent === "object" && policyContent !== null + ? [policyContent as JSONContent] + : []; - const handleSavePolicy = async ( - policyContent: JSONContent[], - ): Promise => { - if (!policyId) return; + const handleSavePolicy = async ( + policyContent: JSONContent[], + ): Promise => { + if (!policyId) return; - try { - await updatePolicy({ policyId, content: policyContent }); - } catch (error) { - console.error("Error saving policy:", error); - throw error; - } - }; + try { + await updatePolicy({ policyId, content: policyContent }); + } catch (error) { + console.error("Error saving policy:", error); + throw error; + } + }; - return ( -
- -
- ); + return ( +
+ +
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyHeader.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyHeader.tsx index 4c7b31e778..d3c3fd4b1c 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyHeader.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/components/PolicyHeader.tsx @@ -1,17 +1,17 @@ import { Badge } from "@comp/ui/badge"; interface PolicyHeaderProps { - saveStatus: "Saved" | "Saving" | "Unsaved"; + saveStatus: "Saved" | "Saving" | "Unsaved"; } export function PolicyHeader({ saveStatus }: PolicyHeaderProps) { - return ( -
-
-
- {saveStatus} -
-
-
- ); + return ( +
+
+
+ {saveStatus} +
+
+
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/types/index.ts b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/types/index.ts index fe4a0e2b68..bf6faa6c97 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/types/index.ts +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/editor/types/index.ts @@ -1,24 +1,24 @@ import { z } from "zod"; export const policyDetailsSchema = z.object({ - id: z.string(), - status: z.enum(["draft", "published", "archived"]), - content: z.array(z.any()), - createdAt: z.date(), - updatedAt: z.date(), - name: z.string(), - description: z.string().nullable(), + id: z.string(), + status: z.enum(["draft", "published", "archived"]), + content: z.array(z.any()), + createdAt: z.date(), + updatedAt: z.date(), + name: z.string(), + description: z.string().nullable(), }); export const policyDetailsInputSchema = z.object({ - policyId: z.string(), - _cache: z.number().optional(), + policyId: z.string(), + _cache: z.number().optional(), }); export const updatePolicySchema = z.object({ - policyId: z.string(), - content: z.any().optional(), - status: z.enum(["draft", "published", "archived"]).optional(), + policyId: z.string(), + content: z.any().optional(), + status: z.enum(["draft", "published", "archived"]).optional(), }); export type PolicyDetails = z.infer; @@ -26,21 +26,21 @@ export type PolicyDetailsInput = z.infer; export type UpdatePolicyInput = z.infer; export type AppError = { - code: "NOT_FOUND" | "UNAUTHORIZED" | "UNEXPECTED_ERROR"; - message: string; + code: "NOT_FOUND" | "UNAUTHORIZED" | "UNEXPECTED_ERROR"; + message: string; }; export const appErrors = { - NOT_FOUND: { - code: "NOT_FOUND" as const, - message: "Policy not found", - }, - UNAUTHORIZED: { - code: "UNAUTHORIZED" as const, - message: "You are not authorized to view this policy", - }, - UNEXPECTED_ERROR: { - code: "UNEXPECTED_ERROR" as const, - message: "An unexpected error occurred", - }, + NOT_FOUND: { + code: "NOT_FOUND" as const, + message: "Policy not found", + }, + UNAUTHORIZED: { + code: "UNAUTHORIZED" as const, + message: "You are not authorized to view this policy", + }, + UNEXPECTED_ERROR: { + code: "UNEXPECTED_ERROR" as const, + message: "An unexpected error occurred", + }, } as const; 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 da8bd007b0..ce3b8049d9 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/page.tsx @@ -2,48 +2,52 @@ import PageWithBreadcrumb from "@/components/pages/PageWithBreadcrumb"; import type { Metadata } from "next"; import PolicyPage from "./components/PolicyPage"; import { - getAssignees, - getComments, - getLogsForPolicy, - getPolicy, - getPolicyControlMappingInfo, + getAssignees, + getComments, + getLogsForPolicy, + getPolicy, + getPolicyControlMappingInfo, } from "./data"; -export default async function PolicyDetails({ params }: { params: Promise<{ policyId: string; orgId: string }> }) { - const { policyId, orgId } = await params; +export default async function PolicyDetails({ + params, +}: { + params: Promise<{ policyId: string; orgId: string }>; +}) { + const { policyId, orgId } = await params; - const policy = await getPolicy(policyId); - const assignees = await getAssignees(); - const comments = await getComments(policyId); - const { mappedControls, allControls } = - await getPolicyControlMappingInfo(policyId); - const logs = await getLogsForPolicy(policyId); + const policy = await getPolicy(policyId); + const assignees = await getAssignees(); + const comments = await getComments(policyId); + const { mappedControls, allControls } = + await getPolicyControlMappingInfo(policyId); + const logs = await getLogsForPolicy(policyId); - const isPendingApproval = !!policy?.approverId; + const isPendingApproval = !!policy?.approverId; - return ( - - - - ); + return ( + + + + ); } export async function generateMetadata(): Promise { - return { - title: "Policy Overview", - }; + return { + title: "Policy Overview", + }; } diff --git a/apps/app/src/app/(app)/[orgId]/policies/all/components/policies-table-columns.tsx b/apps/app/src/app/(app)/[orgId]/policies/all/components/policies-table-columns.tsx index 12e6e2b8db..a3ce7cbac2 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/all/components/policies-table-columns.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/all/components/policies-table-columns.tsx @@ -7,62 +7,62 @@ import { Policy } from "@comp/db/types"; import { ColumnDef } from "@tanstack/react-table"; export function getPolicyColumns(): ColumnDef[] { - return [ - { - id: "name", - accessorKey: "name", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - return ( -
- - {row.getValue("name")} - -
- ); - }, - meta: { - label: "Policy Name", - placeholder: "Search for a policy...", - variant: "text", - }, - enableColumnFilter: true, - }, - { - id: "status", - accessorKey: "status", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - return ; - }, - meta: { - label: "Status", - placeholder: "Search status...", - variant: "select", - }, - }, - { - id: "updatedAt", - accessorKey: "updatedAt", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - return ( -
- {formatDate(row.getValue("updatedAt"))} -
- ); - }, - meta: { - label: "Last Updated", - placeholder: "Search last updated...", - variant: "date", - }, - }, - ]; + return [ + { + id: "name", + accessorKey: "name", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + return ( +
+ + {row.getValue("name")} + +
+ ); + }, + meta: { + label: "Policy Name", + placeholder: "Search for a policy...", + variant: "text", + }, + enableColumnFilter: true, + }, + { + id: "status", + accessorKey: "status", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + return ; + }, + meta: { + label: "Status", + placeholder: "Search status...", + variant: "select", + }, + }, + { + id: "updatedAt", + accessorKey: "updatedAt", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + return ( +
+ {formatDate(row.getValue("updatedAt"))} +
+ ); + }, + meta: { + label: "Last Updated", + placeholder: "Search last updated...", + variant: "date", + }, + }, + ]; } diff --git a/apps/app/src/app/(app)/[orgId]/policies/all/components/policies-table.tsx b/apps/app/src/app/(app)/[orgId]/policies/all/components/policies-table.tsx index 9ba80357c7..4270cc2d69 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/all/components/policies-table.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/all/components/policies-table.tsx @@ -11,43 +11,43 @@ import { getPolicies } from "../data/queries"; import { getPolicyColumns } from "./policies-table-columns"; interface PoliciesTableProps { - promises: Promise<[Awaited>]>; + promises: Promise<[Awaited>]>; } export function PoliciesTable({ promises }: PoliciesTableProps) { - const [{ data, pageCount }] = React.use(promises); - const { orgId } = useParams(); + const [{ data, pageCount }] = React.use(promises); + const { orgId } = useParams(); - const columns = React.useMemo(() => getPolicyColumns(), []); + const columns = React.useMemo(() => getPolicyColumns(), []); - const { table } = useDataTable({ - data, - columns, - pageCount, - initialState: { - columnPinning: { right: ["actions"] }, - }, - getRowId: (originalRow) => originalRow.id, - shallow: false, - clearOnDefault: true, - }); + const { table } = useDataTable({ + data, + columns, + pageCount, + initialState: { + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => originalRow.id, + shallow: false, + clearOnDefault: true, + }); - return ( - <> - row.id} - rowClickBasePath={`/${orgId}/policies`} - > - - {/* */} - - - - - ); + return ( + <> + row.id} + rowClickBasePath={`/${orgId}/policies`} + > + + {/* */} + + + + + ); } diff --git a/apps/app/src/app/(app)/[orgId]/policies/all/data/queries.ts b/apps/app/src/app/(app)/[orgId]/policies/all/data/queries.ts index 5ba71da719..c4560cd6b6 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/all/data/queries.ts +++ b/apps/app/src/app/(app)/[orgId]/policies/all/data/queries.ts @@ -8,51 +8,51 @@ import { cache } from "react"; import type { GetPolicySchema } from "./validations"; export async function getPolicies(input: GetPolicySchema) { - return await cache(async () => { - try { - const session = await auth.api.getSession({ - headers: await headers(), - }); - const organizationId = session?.session.activeOrganizationId; - - if (!organizationId) { - throw new Error("Organization not found"); - } - - const orderBy = input.sort.map((sort) => ({ - [sort.id]: sort.desc ? "desc" : "asc", - })); - - const where: Prisma.PolicyWhereInput = { - organizationId, - ...(input.name && { - name: { - contains: input.name, - mode: Prisma.QueryMode.insensitive, - }, - }), - ...(input.status.length > 0 && { - status: { - in: input.status, - }, - }), - }; - - const policies = await db.policy.findMany({ - where, - orderBy: orderBy.length > 0 ? orderBy : [{ createdAt: "desc" }], - skip: (input.page - 1) * input.perPage, - take: input.perPage, - }); - - const total = await db.policy.count({ - where, - }); - - const pageCount = Math.ceil(total / input.perPage); - return { data: policies, pageCount }; - } catch (_err) { - return { data: [], pageCount: 0 }; - } - })(); + return await cache(async () => { + try { + const session = await auth.api.getSession({ + headers: await headers(), + }); + const organizationId = session?.session.activeOrganizationId; + + if (!organizationId) { + throw new Error("Organization not found"); + } + + const orderBy = input.sort.map((sort) => ({ + [sort.id]: sort.desc ? "desc" : "asc", + })); + + const where: Prisma.PolicyWhereInput = { + organizationId, + ...(input.name && { + name: { + contains: input.name, + mode: Prisma.QueryMode.insensitive, + }, + }), + ...(input.status.length > 0 && { + status: { + in: input.status, + }, + }), + }; + + const policies = await db.policy.findMany({ + where, + orderBy: orderBy.length > 0 ? orderBy : [{ createdAt: "desc" }], + skip: (input.page - 1) * input.perPage, + take: input.perPage, + }); + + const total = await db.policy.count({ + where, + }); + + const pageCount = Math.ceil(total / input.perPage); + return { data: policies, pageCount }; + } catch (_err) { + return { data: [], pageCount: 0 }; + } + })(); } diff --git a/apps/app/src/app/(app)/[orgId]/policies/all/data/validations.ts b/apps/app/src/app/(app)/[orgId]/policies/all/data/validations.ts index d3b413971a..e91e349c01 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/all/data/validations.ts +++ b/apps/app/src/app/(app)/[orgId]/policies/all/data/validations.ts @@ -1,28 +1,28 @@ import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"; import { Policy, PolicyStatus } from "@comp/db/types"; import { - createSearchParamsCache, - parseAsArrayOf, - parseAsInteger, - parseAsString, - parseAsStringEnum, + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, } from "nuqs/server"; import * as z from "zod"; export const searchParamsCache = createSearchParamsCache({ - page: parseAsInteger.withDefault(1), - perPage: parseAsInteger.withDefault(50), - sort: getSortingStateParser().withDefault([ - { id: "name", desc: false }, - ]), - name: parseAsString.withDefault(""), - status: parseAsArrayOf(z.nativeEnum(PolicyStatus)).withDefault([]), - createdAt: parseAsArrayOf(z.coerce.date()).withDefault([]), - // advanced filter - filters: getFiltersStateParser().withDefault([]), - joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(50), + sort: getSortingStateParser().withDefault([ + { id: "name", desc: false }, + ]), + name: parseAsString.withDefault(""), + status: parseAsArrayOf(z.nativeEnum(PolicyStatus)).withDefault([]), + createdAt: parseAsArrayOf(z.coerce.date()).withDefault([]), + // advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), }); export type GetPolicySchema = Awaited< - ReturnType + ReturnType >; diff --git a/apps/app/src/app/(app)/[orgId]/policies/all/loading.tsx b/apps/app/src/app/(app)/[orgId]/policies/all/loading.tsx index 29ba804914..4c6a62ba5f 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/all/loading.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/all/loading.tsx @@ -1,20 +1,12 @@ import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; export default function Loading() { - return ( - - ); + return ( + + ); } diff --git a/apps/app/src/app/(app)/[orgId]/policies/all/page.tsx b/apps/app/src/app/(app)/[orgId]/policies/all/page.tsx index eba04fbf09..4fb2966d02 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/all/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/all/page.tsx @@ -7,32 +7,30 @@ import { getPolicies } from "./data/queries"; import { searchParamsCache } from "./data/validations"; interface PolicyTableProps { - searchParams: Promise; + searchParams: Promise; } export default async function PoliciesPage({ ...props }: PolicyTableProps) { - const searchParams = await props.searchParams; - const search = searchParamsCache.parse(searchParams); - const validFilters = getValidFilters(search.filters); + const searchParams = await props.searchParams; + const search = searchParamsCache.parse(searchParams); + const validFilters = getValidFilters(search.filters); - const promises = Promise.all([ - getPolicies({ - ...search, - filters: validFilters, - }), - ]); + const promises = Promise.all([ + getPolicies({ + ...search, + filters: validFilters, + }), + ]); - return ( - - - - ); + return ( + + + + ); } export async function generateMetadata(): Promise { - return { - title: "Policies", - }; + return { + title: "Policies", + }; } diff --git a/apps/app/src/app/(app)/[orgId]/policies/layout.tsx b/apps/app/src/app/(app)/[orgId]/policies/layout.tsx index 88ce7fd65f..e5eb6de031 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/layout.tsx @@ -1,29 +1,29 @@ import { SecondaryMenu } from "@comp/ui/secondary-menu"; interface LayoutProps { - children: React.ReactNode; - params: Promise<{ policyId: string; orgId: string }>; + children: React.ReactNode; + params: Promise<{ policyId: string; orgId: string }>; } export default async function Layout({ children, params }: LayoutProps) { - const { orgId } = await params; + const { orgId } = await params; - return ( -
- -
{children}
-
- ); + return ( +
+ +
{children}
+
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/risk/(overview)/RisksTable.tsx b/apps/app/src/app/(app)/[orgId]/risk/(overview)/RisksTable.tsx index 27a92c8d4d..140ac086c6 100644 --- a/apps/app/src/app/(app)/[orgId]/risk/(overview)/RisksTable.tsx +++ b/apps/app/src/app/(app)/[orgId]/risk/(overview)/RisksTable.tsx @@ -15,54 +15,54 @@ import { columns as getColumns } from "./components/table/RiskColumns"; export type RiskRow = Risk & { assignee: User | null }; export const RisksTable = ({ - risks, - assignees, - pageCount, + risks, + assignees, + pageCount, }: { - risks: RiskRow[]; - assignees: (Member & { user: User })[]; - pageCount: number; + risks: RiskRow[]; + assignees: (Member & { user: User })[]; + pageCount: number; }) => { - const session = useSession(); - const orgId = session?.data?.session?.activeOrganizationId; - const [_, setOpenSheet] = useQueryState("create-risk-sheet"); + const session = useSession(); + const orgId = session?.data?.session?.activeOrganizationId; + const [_, setOpenSheet] = useQueryState("create-risk-sheet"); - const columns = useMemo[]>( - () => getColumns(orgId ?? ""), - [orgId], - ); + const columns = useMemo[]>( + () => getColumns(orgId ?? ""), + [orgId], + ); - const { table } = useDataTable({ - data: risks, - columns, - pageCount, - getRowId: (row) => row.id, - initialState: { - pagination: { - pageSize: 50, - pageIndex: 0, - }, - sorting: [{ id: "title", desc: true }], - columnPinning: { right: ["actions"] }, - }, - shallow: false, - clearOnDefault: true, - }); + const { table } = useDataTable({ + data: risks, + columns, + pageCount, + getRowId: (row) => row.id, + initialState: { + pagination: { + pageSize: 50, + pageIndex: 0, + }, + sorting: [{ id: "title", desc: true }], + columnPinning: { right: ["actions"] }, + }, + shallow: false, + clearOnDefault: true, + }); - return ( - <> - row.id} - rowClickBasePath={`/${orgId}/risk`} - > - - - - - ); + return ( + <> + row.id} + rowClickBasePath={`/${orgId}/risk`} + > + + + + + ); }; diff --git a/apps/app/src/app/(app)/[orgId]/risk/(overview)/components/table/RiskColumns.tsx b/apps/app/src/app/(app)/[orgId]/risk/(overview)/components/table/RiskColumns.tsx index 208a708f4c..018cd2ebea 100644 --- a/apps/app/src/app/(app)/[orgId]/risk/(overview)/components/table/RiskColumns.tsx +++ b/apps/app/src/app/(app)/[orgId]/risk/(overview)/components/table/RiskColumns.tsx @@ -8,107 +8,107 @@ import Link from "next/link"; import { RiskRow } from "../../RisksTable"; export const columns = (orgId: string): ColumnDef[] => [ - { - id: "title", - accessorKey: "title", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - return ( - - - {row.original.title} - - - ); - }, - meta: { - label: "Risk", - placeholder: "Search for a risk...", - variant: "text", - }, - size: 250, - minSize: 200, - maxSize: 300, - enableColumnFilter: true, - enableSorting: true, - }, - { - id: "status", - accessorKey: "status", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - return ; - }, - meta: { - label: "Status", - }, - enableColumnFilter: true, - enableSorting: true, - }, - { - id: "department", - accessorKey: "department", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - return ( - - {row.original.department} - - ); - }, - meta: { - label: "Department", - }, - enableColumnFilter: true, - enableSorting: true, - }, - { - id: "assignee", - accessorKey: "assignee.name", - header: ({ column }) => ( - - ), - enableSorting: false, - cell: ({ row }) => { - if (!row.original.assignee) { - return ( -
-
- -
-

- None -

-
- ); - } + { + id: "title", + accessorKey: "title", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + return ( + + {row.original.title} + + ); + }, + meta: { + label: "Risk", + placeholder: "Search for a risk...", + variant: "text", + }, + size: 250, + minSize: 200, + maxSize: 300, + enableColumnFilter: true, + enableSorting: true, + }, + { + id: "status", + accessorKey: "status", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + return ; + }, + meta: { + label: "Status", + }, + enableColumnFilter: true, + enableSorting: true, + }, + { + id: "department", + accessorKey: "department", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + return ( + + {row.original.department} + + ); + }, + meta: { + label: "Department", + }, + enableColumnFilter: true, + enableSorting: true, + }, + { + id: "assignee", + accessorKey: "assignee.name", + header: ({ column }) => ( + + ), + enableSorting: false, + cell: ({ row }) => { + if (!row.original.assignee) { + return ( +
+
+ +
+

None

+
+ ); + } - return ( -
- - - - {row.original.assignee.name?.charAt(0) || row.original.assignee.email?.charAt(0).toUpperCase() || "?"} - - -

- {row.original.assignee.name || row.original.assignee.email} -

-
- ); - }, - meta: { - label: "Assignee", - }, - enableColumnFilter: true, - }, + return ( +
+ + + + {row.original.assignee.name?.charAt(0) || + row.original.assignee.email?.charAt(0).toUpperCase() || + "?"} + + +

+ {row.original.assignee.name || row.original.assignee.email} +

+
+ ); + }, + meta: { + label: "Assignee", + }, + enableColumnFilter: true, + }, ]; diff --git a/apps/app/src/app/(app)/[orgId]/risk/(overview)/data/getRisks.ts b/apps/app/src/app/(app)/[orgId]/risk/(overview)/data/getRisks.ts index de58266769..baffa1fe59 100644 --- a/apps/app/src/app/(app)/[orgId]/risk/(overview)/data/getRisks.ts +++ b/apps/app/src/app/(app)/[orgId]/risk/(overview)/data/getRisks.ts @@ -7,80 +7,80 @@ import { headers } from "next/headers"; import type { GetRiskSchema } from "./validations"; export async function getRisks(input: GetRiskSchema): Promise<{ - data: (Omit< - Prisma.RiskGetPayload<{ - include: { assignee: { include: { user: true } } }; - }>, - "assignee" - > & { assignee: User | null })[]; - pageCount: number; + data: (Omit< + Prisma.RiskGetPayload<{ + include: { assignee: { include: { user: true } } }; + }>, + "assignee" + > & { assignee: User | null })[]; + pageCount: number; }> { - const session = await auth.api.getSession({ - headers: await headers(), - }); + const session = await auth.api.getSession({ + headers: await headers(), + }); - if (!session?.session.activeOrganizationId) { - // In case of unauthorized or missing org, return empty data and 0 pageCount - return { data: [], pageCount: 0 }; - } + if (!session?.session.activeOrganizationId) { + // In case of unauthorized or missing org, return empty data and 0 pageCount + return { data: [], pageCount: 0 }; + } - const { title, page, perPage, sort, filters, joinOperator } = input; + const { title, page, perPage, sort, filters, joinOperator } = input; - const orderBy = sort.map((s) => ({ - [s.id]: s.desc ? "desc" : "asc", - })); + const orderBy = sort.map((s) => ({ + [s.id]: s.desc ? "desc" : "asc", + })); - const filterConditions: Prisma.RiskWhereInput[] = filters.map((filter) => { - // Basic handling, assuming 'eq' or 'in' based on value type for now - // This might need to be more sophisticated based on actual filter operators - const value = Array.isArray(filter.value) - ? { in: filter.value } - : filter.value; - return { [filter.id]: value }; - }); + const filterConditions: Prisma.RiskWhereInput[] = filters.map((filter) => { + // Basic handling, assuming 'eq' or 'in' based on value type for now + // This might need to be more sophisticated based on actual filter operators + const value = Array.isArray(filter.value) + ? { in: filter.value } + : filter.value; + return { [filter.id]: value }; + }); - const where: Prisma.RiskWhereInput = { - organizationId: session.session.activeOrganizationId, - ...(title && { - title: { - contains: title, - mode: Prisma.QueryMode.insensitive, - }, - }), - ...(filterConditions.length > 0 && { - [joinOperator.toUpperCase()]: filterConditions, - }), - }; + const where: Prisma.RiskWhereInput = { + organizationId: session.session.activeOrganizationId, + ...(title && { + title: { + contains: title, + mode: Prisma.QueryMode.insensitive, + }, + }), + ...(filterConditions.length > 0 && { + [joinOperator.toUpperCase()]: filterConditions, + }), + }; - const skip = (page - 1) * perPage; - const take = perPage; + const skip = (page - 1) * perPage; + const take = perPage; - const risksData = await db.risk.findMany({ - where, - skip, - take, - include: { - assignee: { - include: { - user: true, - }, - }, - }, - orderBy: orderBy.length > 0 ? orderBy : [{ createdAt: "desc" }], - }); + const risksData = await db.risk.findMany({ + where, + skip, + take, + include: { + assignee: { + include: { + user: true, + }, + }, + }, + orderBy: orderBy.length > 0 ? orderBy : [{ createdAt: "desc" }], + }); - const totalRisks = await db.risk.count({ where }); + const totalRisks = await db.risk.count({ where }); - const pageCount = Math.ceil(totalRisks / perPage); + const pageCount = Math.ceil(totalRisks / perPage); - // Transform the data to match the expected structure (assignee as User | null) - const transformedRisks = risksData.map((risk) => ({ - ...risk, - assignee: risk.assignee ? risk.assignee.user : null, - })); + // Transform the data to match the expected structure (assignee as User | null) + const transformedRisks = risksData.map((risk) => ({ + ...risk, + assignee: risk.assignee ? risk.assignee.user : null, + })); - return { - data: transformedRisks, - pageCount, - }; + return { + data: transformedRisks, + pageCount, + }; } diff --git a/apps/app/src/app/(app)/[orgId]/risk/(overview)/data/validations.ts b/apps/app/src/app/(app)/[orgId]/risk/(overview)/data/validations.ts index fcd6a73728..1cfb3b3288 100644 --- a/apps/app/src/app/(app)/[orgId]/risk/(overview)/data/validations.ts +++ b/apps/app/src/app/(app)/[orgId]/risk/(overview)/data/validations.ts @@ -1,25 +1,25 @@ import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"; import { Risk } from "@comp/db/types"; import { - createSearchParamsCache, - parseAsArrayOf, - parseAsInteger, - parseAsString, - parseAsStringEnum, + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, } from "nuqs/server"; import * as z from "zod"; export const searchParamsCache = createSearchParamsCache({ - page: parseAsInteger.withDefault(1), - perPage: parseAsInteger.withDefault(50), - sort: getSortingStateParser().withDefault([ - { id: "title", desc: true }, - ]), - title: parseAsString.withDefault(""), - lastUpdated: parseAsArrayOf(z.coerce.date()).withDefault([]), - // advanced filter - filters: getFiltersStateParser().withDefault([]), - joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(50), + sort: getSortingStateParser().withDefault([ + { id: "title", desc: true }, + ]), + title: parseAsString.withDefault(""), + lastUpdated: parseAsArrayOf(z.coerce.date()).withDefault([]), + // advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), }); export type GetRiskSchema = Awaited>; diff --git a/apps/app/src/app/(app)/[orgId]/risk/(overview)/loading.tsx b/apps/app/src/app/(app)/[orgId]/risk/(overview)/loading.tsx index e46c807a99..9636e0b23a 100644 --- a/apps/app/src/app/(app)/[orgId]/risk/(overview)/loading.tsx +++ b/apps/app/src/app/(app)/[orgId]/risk/(overview)/loading.tsx @@ -2,16 +2,16 @@ import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; import { Suspense } from "react"; export default function Loading() { - return ( - - } - /> - ); + return ( + + } + /> + ); } diff --git a/apps/app/src/app/(app)/[orgId]/risk/(overview)/page.tsx b/apps/app/src/app/(app)/[orgId]/risk/(overview)/page.tsx index be1fe06660..341464267a 100644 --- a/apps/app/src/app/(app)/[orgId]/risk/(overview)/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/risk/(overview)/page.tsx @@ -13,106 +13,109 @@ import { searchParamsCache } from "./data/validations"; import { getValidFilters } from "@/lib/data-table"; export default async function RiskRegisterPage(props: { - params: Promise<{ orgId: string }>; - searchParams: Promise<{ - search: string; - page: string; - perPage: string; - status: string; - department: string; - assigneeId: string; - }>; + params: Promise<{ orgId: string }>; + searchParams: Promise<{ + search: string; + page: string; + perPage: string; + status: string; + department: string; + assigneeId: string; + }>; }) { - const { params } = props; - const { orgId } = await params; + const { params } = props; + const { orgId } = await params; - const searchParams = await props.searchParams; - const search = searchParamsCache.parse(searchParams); - const validFilters = getValidFilters(search.filters); + const searchParams = await props.searchParams; + const search = searchParamsCache.parse(searchParams); + const validFilters = getValidFilters(search.filters); - const risksResult = await getRisks({ - ...search, - filters: validFilters, - }); + const risksResult = await getRisks({ + ...search, + filters: validFilters, + }); - const assignees = await getAssignees(); + const assignees = await getAssignees(); - if ( - risksResult.data?.length === 0 && - search.page === 1 && - search.title === "" && - validFilters.length === 0 - ) { - return ( -
- - -
- ); - } + if ( + risksResult.data?.length === 0 && + search.page === 1 && + search.title === "" && + validFilters.length === 0 + ) { + return ( +
+ + +
+ ); + } - return ( - - - - ); + return ( + + + + ); } export async function generateMetadata(): Promise { - return { - title: "Risks", - }; + return { + title: "Risks", + }; } const getAssignees = cache(async () => { - const session = await auth.api.getSession({ - headers: await headers(), - }); + const session = await auth.api.getSession({ + headers: await headers(), + }); - if (!session || !session.session.activeOrganizationId) { - return []; - } + if (!session || !session.session.activeOrganizationId) { + return []; + } - return await db.member.findMany({ - where: { - organizationId: session.session.activeOrganizationId, - isActive: true, - role: { - notIn: ["employee"], - }, - }, - include: { - user: true, - }, - }); + return await db.member.findMany({ + where: { + organizationId: session.session.activeOrganizationId, + isActive: true, + role: { + notIn: ["employee"], + }, + }, + include: { + user: true, + }, + }); }); diff --git a/apps/app/src/app/(app)/[orgId]/risk/(overview)/search-params.ts b/apps/app/src/app/(app)/[orgId]/risk/(overview)/search-params.ts index db2c803475..b51788dfc6 100644 --- a/apps/app/src/app/(app)/[orgId]/risk/(overview)/search-params.ts +++ b/apps/app/src/app/(app)/[orgId]/risk/(overview)/search-params.ts @@ -1,15 +1,15 @@ import { - createSearchParamsCache, - parseAsInteger, - parseAsString, + createSearchParamsCache, + parseAsInteger, + parseAsString, } from "nuqs/server"; export const searchParamsCache = createSearchParamsCache({ - q: parseAsString, - page: parseAsInteger.withDefault(0), - start: parseAsString, - end: parseAsString, - status: parseAsString, - department: parseAsString, - assigneeId: parseAsString, + q: parseAsString, + page: parseAsInteger.withDefault(0), + start: parseAsString, + end: parseAsString, + status: parseAsString, + department: parseAsString, + assigneeId: parseAsString, }); diff --git a/apps/app/src/app/(app)/[orgId]/risk/[riskId]/page.tsx b/apps/app/src/app/(app)/[orgId]/risk/[riskId]/page.tsx index cafebcfd38..caa8b83ba7 100644 --- a/apps/app/src/app/(app)/[orgId]/risk/[riskId]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/risk/[riskId]/page.tsx @@ -10,156 +10,154 @@ import { headers } from "next/headers"; import { redirect } from "next/navigation"; import { cache } from "react"; import { - Comments, - CommentWithAuthor, + Comments, + CommentWithAuthor, } from "../../../../../components/comments/Comments"; interface PageProps { - searchParams: Promise<{ - search?: string; - status?: string; - sort?: string; - page?: string; - per_page?: string; - }>; - params: Promise<{ riskId: string; orgId: string }>; + searchParams: Promise<{ + search?: string; + status?: string; + sort?: string; + page?: string; + per_page?: string; + }>; + params: Promise<{ riskId: string; orgId: string }>; } export default async function RiskPage({ searchParams, params }: PageProps) { - const { riskId, orgId } = await params; - const risk = await getRisk(riskId); - const comments = await getComments(riskId); - const assignees = await getAssignees(); - if (!risk) { - redirect("/"); - } - - return ( - -
- -
- - -
- -
-
- ); + const { riskId, orgId } = await params; + const risk = await getRisk(riskId); + const comments = await getComments(riskId); + const assignees = await getAssignees(); + if (!risk) { + redirect("/"); + } + + return ( + +
+ +
+ + +
+ +
+
+ ); } const getRisk = cache(async (riskId: string) => { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - if (!session || !session.session.activeOrganizationId) { - return null; - } - - const risk = await db.risk.findUnique({ - where: { - id: riskId, - organizationId: session.session.activeOrganizationId, - }, - include: { - assignee: { - include: { - user: true, - }, - }, - }, - }); - - return risk; + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session || !session.session.activeOrganizationId) { + return null; + } + + const risk = await db.risk.findUnique({ + where: { + id: riskId, + organizationId: session.session.activeOrganizationId, + }, + include: { + assignee: { + include: { + user: true, + }, + }, + }, + }); + + return risk; }); const getComments = async (riskId: string): Promise => { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - const activeOrgId = session?.session.activeOrganizationId; - - if (!activeOrgId) { - console.warn( - "Could not determine active organization ID in getComments", - ); - return []; - } - - const comments = await db.comment.findMany({ - where: { - organizationId: activeOrgId, - entityId: riskId, - entityType: CommentEntityType.risk, - }, - include: { - author: { - include: { - user: true, - }, - }, - }, - orderBy: { - createdAt: "desc", - }, - }); - - const commentsWithAttachments = await Promise.all( - comments.map(async (comment) => { - const attachments = await db.attachment.findMany({ - where: { - organizationId: activeOrgId, - entityId: comment.id, - entityType: AttachmentEntityType.comment, - }, - }); - return { - ...comment, - attachments, - }; - }), - ); - - return commentsWithAttachments; + const session = await auth.api.getSession({ + headers: await headers(), + }); + + const activeOrgId = session?.session.activeOrganizationId; + + if (!activeOrgId) { + console.warn("Could not determine active organization ID in getComments"); + return []; + } + + const comments = await db.comment.findMany({ + where: { + organizationId: activeOrgId, + entityId: riskId, + entityType: CommentEntityType.risk, + }, + include: { + author: { + include: { + user: true, + }, + }, + }, + orderBy: { + createdAt: "desc", + }, + }); + + const commentsWithAttachments = await Promise.all( + comments.map(async (comment) => { + const attachments = await db.attachment.findMany({ + where: { + organizationId: activeOrgId, + entityId: comment.id, + entityType: AttachmentEntityType.comment, + }, + }); + return { + ...comment, + attachments, + }; + }), + ); + + return commentsWithAttachments; }; const getAssignees = cache(async () => { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - if (!session || !session.session.activeOrganizationId) { - return []; - } - - const assignees = await db.member.findMany({ - where: { - organizationId: session.session.activeOrganizationId, - role: { - notIn: ["employee"], - }, - }, - include: { - user: true, - }, - }); - - return assignees; + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session || !session.session.activeOrganizationId) { + return []; + } + + const assignees = await db.member.findMany({ + where: { + organizationId: session.session.activeOrganizationId, + role: { + notIn: ["employee"], + }, + }, + include: { + user: true, + }, + }); + + return assignees; }); export async function generateMetadata(): Promise { - return { - title: "Risk Overview", - }; + return { + title: "Risk Overview", + }; } diff --git a/apps/app/src/app/(app)/[orgId]/risk/[riskId]/tasks/[taskId]/page.tsx b/apps/app/src/app/(app)/[orgId]/risk/[riskId]/tasks/[taskId]/page.tsx index 9d47592f6f..a940b711b4 100644 --- a/apps/app/src/app/(app)/[orgId]/risk/[riskId]/tasks/[taskId]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/risk/[riskId]/tasks/[taskId]/page.tsx @@ -7,49 +7,49 @@ import { headers } from "next/headers"; import { redirect } from "next/navigation"; import { cache } from "react"; interface PageProps { - params: Promise<{ riskId: string; taskId: string }>; + params: Promise<{ riskId: string; taskId: string }>; } export default async function RiskPage({ params }: PageProps) { - const { riskId, taskId } = await params; - const task = await getTask(riskId, taskId); - const users = await useUsers(); - - if (!task) { - redirect("/"); - } - - return ( -
- -
- ); + const { riskId, taskId } = await params; + const task = await getTask(riskId, taskId); + const users = await useUsers(); + + if (!task) { + redirect("/"); + } + + return ( +
+ +
+ ); } const getTask = cache(async (riskId: string, taskId: string) => { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - if (!session || !session.session.activeOrganizationId) { - redirect("/"); - } - - const task = await db.task.findUnique({ - where: { - id: taskId, - organizationId: session.session.activeOrganizationId, - }, - include: { - assignee: true, - }, - }); - - return task; + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session || !session.session.activeOrganizationId) { + redirect("/"); + } + + const task = await db.task.findUnique({ + where: { + id: taskId, + organizationId: session.session.activeOrganizationId, + }, + include: { + assignee: true, + }, + }); + + return task; }); export async function generateMetadata(): Promise { - return { - title: "Task Overview", - }; + return { + title: "Task Overview", + }; } diff --git a/apps/app/src/app/(app)/[orgId]/risk/[riskId]/tasks/search-params.ts b/apps/app/src/app/(app)/[orgId]/risk/[riskId]/tasks/search-params.ts index 11805dce5e..1f1d026e22 100644 --- a/apps/app/src/app/(app)/[orgId]/risk/[riskId]/tasks/search-params.ts +++ b/apps/app/src/app/(app)/[orgId]/risk/[riskId]/tasks/search-params.ts @@ -1,14 +1,14 @@ import { - createSearchParamsCache, - parseAsInteger, - parseAsString, + createSearchParamsCache, + parseAsInteger, + parseAsString, } from "nuqs/server"; export const searchParamsCache = createSearchParamsCache({ - q: parseAsString, - page: parseAsInteger.withDefault(0), - start: parseAsString, - end: parseAsString, - status: parseAsString, - assigneeId: parseAsString, + q: parseAsString, + page: parseAsInteger.withDefault(0), + start: parseAsString, + end: parseAsString, + status: parseAsString, + assigneeId: parseAsString, }); diff --git a/apps/app/src/app/(app)/[orgId]/risk/layout.tsx b/apps/app/src/app/(app)/[orgId]/risk/layout.tsx index 284a24ae0c..10fd15a634 100644 --- a/apps/app/src/app/(app)/[orgId]/risk/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/risk/layout.tsx @@ -1,7 +1,7 @@ export default async function Layout({ - children, + children, }: { - children: React.ReactNode; + children: React.ReactNode; }) { - return
{children}
; + return
{children}
; } diff --git a/apps/app/src/app/(app)/[orgId]/settings/api-keys/components/CreateApiKeyDialog.tsx b/apps/app/src/app/(app)/[orgId]/settings/api-keys/components/CreateApiKeyDialog.tsx index 250ba95de2..ab0d393f78 100644 --- a/apps/app/src/app/(app)/[orgId]/settings/api-keys/components/CreateApiKeyDialog.tsx +++ b/apps/app/src/app/(app)/[orgId]/settings/api-keys/components/CreateApiKeyDialog.tsx @@ -3,25 +3,25 @@ import { createApiKeyAction } from "@/actions/organization/create-api-key-action"; import { Button } from "@comp/ui/button"; import { - Select, - SelectTrigger, - SelectValue, - SelectContent, - SelectItem, + Select, + SelectTrigger, + SelectValue, + SelectContent, + SelectItem, } from "@comp/ui/select"; import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, } from "@comp/ui/sheet"; import { - Drawer, - DrawerContent, - DrawerDescription, - DrawerHeader, - DrawerTitle, + Drawer, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerTitle, } from "@comp/ui/drawer"; import { useMediaQuery } from "@comp/ui/hooks"; import { ScrollArea } from "@comp/ui/scroll-area"; @@ -32,232 +32,232 @@ import { toast } from "sonner"; import { Input } from "@comp/ui/input"; interface CreateApiKeyDialogProps { - open: boolean; - onOpenChange: (open: boolean) => void; - onSuccess?: () => void; + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess?: () => void; } export function CreateApiKeyDialog({ - open, - onOpenChange, - onSuccess, + open, + onOpenChange, + onSuccess, }: CreateApiKeyDialogProps) { - const isDesktop = useMediaQuery("(min-width: 768px)"); - const [name, setName] = useState(""); - const [expiration, setExpiration] = useState< - "never" | "30days" | "90days" | "1year" - >("never"); - const [createdApiKey, setCreatedApiKey] = useState(null); - const [copied, setCopied] = useState(false); + const isDesktop = useMediaQuery("(min-width: 768px)"); + const [name, setName] = useState(""); + const [expiration, setExpiration] = useState< + "never" | "30days" | "90days" | "1year" + >("never"); + const [createdApiKey, setCreatedApiKey] = useState(null); + const [copied, setCopied] = useState(false); - const { execute: createApiKey, status: isCreating } = useAction( - createApiKeyAction, - { - onSuccess: (data) => { - if (data.data?.data?.key) { - setCreatedApiKey(data.data.data.key); - if (onSuccess) onSuccess(); - } - }, - onError: (error) => { - toast.error("Failed to create API key"); - }, - }, - ); + const { execute: createApiKey, status: isCreating } = useAction( + createApiKeyAction, + { + onSuccess: (data) => { + if (data.data?.data?.key) { + setCreatedApiKey(data.data.data.key); + if (onSuccess) onSuccess(); + } + }, + onError: (error) => { + toast.error("Failed to create API key"); + }, + }, + ); - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); - createApiKey({ - name, - expiresAt: expiration, - }); - }; + createApiKey({ + name, + expiresAt: expiration, + }); + }; - const handleClose = () => { - if (isCreating !== "executing") { - setName(""); - setExpiration("never"); - setCreatedApiKey(null); - setCopied(false); - onOpenChange(false); - } - }; + const handleClose = () => { + if (isCreating !== "executing") { + setName(""); + setExpiration("never"); + setCreatedApiKey(null); + setCopied(false); + onOpenChange(false); + } + }; - const copyToClipboard = async () => { - if (createdApiKey) { - try { - await navigator.clipboard.writeText(createdApiKey); - setCopied(true); - toast.success("API key copied to clipboard"); + const copyToClipboard = async () => { + if (createdApiKey) { + try { + await navigator.clipboard.writeText(createdApiKey); + setCopied(true); + toast.success("API key copied to clipboard"); - // Reset copied state after 2 seconds - setTimeout(() => { - setCopied(false); - }, 2000); - } catch (err) { - toast.error("Error"); - } - } - }; + // Reset copied state after 2 seconds + setTimeout(() => { + setCopied(false); + }, 2000); + } catch (err) { + toast.error("Error"); + } + } + }; - // Form content for reuse in both Dialog and Sheet/Drawer - const renderFormContent = () => ( -
-
- - setName(e.target.value)} - placeholder={"Enter a name for this API key"} - required - className="w-full" - /> -
-
- - -
-
- - -
-
- ); + // Form content for reuse in both Dialog and Sheet/Drawer + const renderFormContent = () => ( +
+
+ + setName(e.target.value)} + placeholder={"Enter a name for this API key"} + required + className="w-full" + /> +
+
+ + +
+
+ + +
+
+ ); - // Created key content for reuse in both Dialog and Sheet/Drawer - const renderCreatedKeyContent = () => ( - <> -
-
-

{"API Key"}

-
-
-
-
- {createdApiKey} -
-
- -
-
-

- {"This key will only be shown once. Make sure to copy it now."} -

-
-
-
- -
- - ); + // Created key content for reuse in both Dialog and Sheet/Drawer + const renderCreatedKeyContent = () => ( + <> +
+
+

{"API Key"}

+
+
+
+
+ {createdApiKey} +
+
+ +
+
+

+ {"This key will only be shown once. Make sure to copy it now."} +

+
+
+
+ +
+ + ); - // Shared content for both Sheet and Drawer - const renderContent = () => - createdApiKey ? renderCreatedKeyContent() : renderFormContent(); + // Shared content for both Sheet and Drawer + const renderContent = () => + createdApiKey ? renderCreatedKeyContent() : renderFormContent(); - if (isDesktop) { - return ( - - - -
- - {createdApiKey ? "API Key Created" : "New API Key"} - - -
- - {createdApiKey - ? "Your API key has been created. Make sure to copy it now as you won't be able to see it again." - : "Create a new API key for programmatic access to your organization's data."} - -
- - {createdApiKey ? ( - <>{renderCreatedKeyContent()} - ) : ( - <>{renderFormContent()} - )} - -
-
- ); - } - return ( - - - - - {createdApiKey ? "API Key Created" : "New API Key"} - - - {createdApiKey - ? "Your API key has been created. Make sure to copy it now as you won't be able to see it again." - : "Create a new API key for programmatic access to your organization's data."} - - - {createdApiKey ? ( - <>{renderCreatedKeyContent()} - ) : ( - <>{renderFormContent()} - )} - - - ); + if (isDesktop) { + return ( + + + +
+ + {createdApiKey ? "API Key Created" : "New API Key"} + + +
+ + {createdApiKey + ? "Your API key has been created. Make sure to copy it now as you won't be able to see it again." + : "Create a new API key for programmatic access to your organization's data."} + +
+ + {createdApiKey ? ( + <>{renderCreatedKeyContent()} + ) : ( + <>{renderFormContent()} + )} + +
+
+ ); + } + return ( + + + + + {createdApiKey ? "API Key Created" : "New API Key"} + + + {createdApiKey + ? "Your API key has been created. Make sure to copy it now as you won't be able to see it again." + : "Create a new API key for programmatic access to your organization's data."} + + + {createdApiKey ? ( + <>{renderCreatedKeyContent()} + ) : ( + <>{renderFormContent()} + )} + + + ); } diff --git a/apps/app/src/app/(app)/[orgId]/settings/api-keys/components/table/ApiKeysColumns.tsx b/apps/app/src/app/(app)/[orgId]/settings/api-keys/components/table/ApiKeysColumns.tsx index 7e5951a9dd..cdf72bfd55 100644 --- a/apps/app/src/app/(app)/[orgId]/settings/api-keys/components/table/ApiKeysColumns.tsx +++ b/apps/app/src/app/(app)/[orgId]/settings/api-keys/components/table/ApiKeysColumns.tsx @@ -5,122 +5,138 @@ import { Trash2 } from "lucide-react"; import { useAction } from "next-safe-action/hooks"; import { useState } from "react"; import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, } from "@comp/ui/alert-dialog"; import { Button } from "@comp/ui/button"; import { revokeApiKeyAction } from "@/actions/organization/revoke-api-key-action"; export const columns = (): ColumnDef[] => [ - { - id: "name", - accessorKey: "name", - header: ({ column }) => ( - - ), - cell: ({ row }) => {row.original.name}, - meta: { label: "Name", variant: "text" }, - enableColumnFilter: true, - enableSorting: true, - size: 200, - minSize: 200, - maxSize: 200, - }, - { - id: "createdAt", - accessorKey: "createdAt", - header: ({ column }) => ( - - ), - cell: ({ row }) => {new Date(row.original.createdAt).toISOString().slice(0, 10)}, - meta: { label: "Created" }, - enableColumnFilter: false, - enableSorting: false, - size: 120, - minSize: 100, - maxSize: 150, - }, - { - id: "expiresAt", - accessorKey: "expiresAt", - header: ({ column }) => ( - - ), - cell: ({ row }) => {row.original.expiresAt ? new Date(row.original.expiresAt).toISOString().slice(0, 10) : "Never"}, - meta: { label: "Expires" }, - enableColumnFilter: false, - enableSorting: false, - size: 120, - minSize: 100, - maxSize: 150, - }, - { - id: "lastUsedAt", - accessorKey: "lastUsedAt", - header: ({ column }) => ( - - ), - cell: ({ row }) => {row.original.lastUsedAt ? new Date(row.original.lastUsedAt).toISOString().slice(0, 10) : "Never"}, - meta: { label: "Last Used" }, - enableColumnFilter: false, - enableSorting: false, - size: 120, - minSize: 100, - maxSize: 150, - }, - { - id: "actions", - header: () => Actions, - cell: ({ row }) => { - const [open, setOpen] = useState(false); - const { execute, status } = useAction(revokeApiKeyAction, { - onSuccess: () => { - setOpen(false); - }, - }); - return ( - - - - - - - {"Revoke API Key"} - - {"Are you sure you want to revoke this API key? This action cannot be undone."} - - - - {"Cancel"} - execute({ id: row.original.id })} - disabled={status === "executing"} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - > - {status === "executing" ? "Revoking..." : "Revoke"} - - - - - ); + { + id: "name", + accessorKey: "name", + header: ({ column }) => ( + + ), + cell: ({ row }) => {row.original.name}, + meta: { label: "Name", variant: "text" }, + enableColumnFilter: true, + enableSorting: true, + size: 200, + minSize: 200, + maxSize: 200, + }, + { + id: "createdAt", + accessorKey: "createdAt", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {new Date(row.original.createdAt).toISOString().slice(0, 10)} + ), + meta: { label: "Created" }, + enableColumnFilter: false, + enableSorting: false, + size: 120, + minSize: 100, + maxSize: 150, + }, + { + id: "expiresAt", + accessorKey: "expiresAt", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.expiresAt + ? new Date(row.original.expiresAt).toISOString().slice(0, 10) + : "Never"} + + ), + meta: { label: "Expires" }, + enableColumnFilter: false, + enableSorting: false, + size: 120, + minSize: 100, + maxSize: 150, + }, + { + id: "lastUsedAt", + accessorKey: "lastUsedAt", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.original.lastUsedAt + ? new Date(row.original.lastUsedAt).toISOString().slice(0, 10) + : "Never"} + + ), + meta: { label: "Last Used" }, + enableColumnFilter: false, + enableSorting: false, + size: 120, + minSize: 100, + maxSize: 150, + }, + { + id: "actions", + header: () => Actions, + cell: ({ row }) => { + const [open, setOpen] = useState(false); + const { execute, status } = useAction(revokeApiKeyAction, { + onSuccess: () => { + setOpen(false); }, - meta: { label: "Actions" }, - enableColumnFilter: false, - enableSorting: false, - size: 60, - minSize: 60, + }); + return ( + + + + + + + {"Revoke API Key"} + + { + "Are you sure you want to revoke this API key? This action cannot be undone." + } + + + + {"Cancel"} + execute({ id: row.original.id })} + disabled={status === "executing"} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {status === "executing" ? "Revoking..." : "Revoke"} + + + + + ); }, -]; \ No newline at end of file + meta: { label: "Actions" }, + enableColumnFilter: false, + enableSorting: false, + size: 60, + minSize: 60, + }, +]; diff --git a/apps/app/src/app/(app)/[orgId]/settings/api-keys/components/table/ApiKeysTable.tsx b/apps/app/src/app/(app)/[orgId]/settings/api-keys/components/table/ApiKeysTable.tsx index 3320a27b7c..e87c0362ba 100644 --- a/apps/app/src/app/(app)/[orgId]/settings/api-keys/components/table/ApiKeysTable.tsx +++ b/apps/app/src/app/(app)/[orgId]/settings/api-keys/components/table/ApiKeysTable.tsx @@ -11,40 +11,40 @@ import { CreateApiKeyDialog } from "../CreateApiKeyDialog"; import { DataTableToolbar } from "@/components/data-table/data-table-toolbar"; export function ApiKeysTable({ apiKeys }: { apiKeys: ApiKey[] }) { - const columns = useMemo(() => getColumns(), []); - const { table } = useDataTable({ - data: apiKeys, - columns, - pageCount: 1, - getRowId: (row) => row.id, - initialState: { - pagination: { - pageSize: 50, - pageIndex: 0, - }, - sorting: [{ id: "createdAt", desc: true }], - }, - shallow: false, - clearOnDefault: true, - }); - const [openSheet, setOpenSheet] = useQueryState("create-api-key-sheet"); - return ( - <> - - - - - - setOpenSheet(open ? "true" : null)} - /> - - ); + const columns = useMemo(() => getColumns(), []); + const { table } = useDataTable({ + data: apiKeys, + columns, + pageCount: 1, + getRowId: (row) => row.id, + initialState: { + pagination: { + pageSize: 50, + pageIndex: 0, + }, + sorting: [{ id: "createdAt", desc: true }], + }, + shallow: false, + clearOnDefault: true, + }); + const [openSheet, setOpenSheet] = useQueryState("create-api-key-sheet"); + return ( + <> + + + + + + setOpenSheet(open ? "true" : null)} + /> + + ); } diff --git a/apps/app/src/app/(app)/[orgId]/settings/api-keys/loading.tsx b/apps/app/src/app/(app)/[orgId]/settings/api-keys/loading.tsx index 101e9e35f9..1bc7f520cb 100644 --- a/apps/app/src/app/(app)/[orgId]/settings/api-keys/loading.tsx +++ b/apps/app/src/app/(app)/[orgId]/settings/api-keys/loading.tsx @@ -1,9 +1,14 @@ import { DataTableSkeleton } from "@/components/data-table/data-table-skeleton"; export default function Loading() { - return ( -
- -
- ); + return ( +
+ +
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/settings/api-keys/page.tsx b/apps/app/src/app/(app)/[orgId]/settings/api-keys/page.tsx index 4bff73ced3..e0753873dc 100644 --- a/apps/app/src/app/(app)/[orgId]/settings/api-keys/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/settings/api-keys/page.tsx @@ -8,52 +8,52 @@ import type { Metadata } from "next"; import PageCore from "@/components/pages/PageCore.tsx"; export default async function ApiKeysPage() { - const apiKeys = await getApiKeys(); + const apiKeys = await getApiKeys(); - return ( - - - - ); + return ( + + + + ); } export async function generateMetadata(): Promise { - return { - title: "API", - }; + return { + title: "API", + }; } const getApiKeys = cache(async () => { - const session = await auth.api.getSession({ - headers: await headers(), - }); - - if (!session?.session.activeOrganizationId) { - return []; - } - - const apiKeys = await db.apiKey.findMany({ - where: { - organizationId: session.session.activeOrganizationId, - isActive: true, - }, - select: { - id: true, - name: true, - createdAt: true, - expiresAt: true, - lastUsedAt: true, - isActive: true, - }, - orderBy: { - createdAt: "desc", - }, - }); - - return apiKeys.map((key) => ({ - ...key, - createdAt: key.createdAt.toISOString(), - expiresAt: key.expiresAt ? key.expiresAt.toISOString() : null, - lastUsedAt: key.lastUsedAt ? key.lastUsedAt.toISOString() : null, - })); + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session?.session.activeOrganizationId) { + return []; + } + + const apiKeys = await db.apiKey.findMany({ + where: { + organizationId: session.session.activeOrganizationId, + isActive: true, + }, + select: { + id: true, + name: true, + createdAt: true, + expiresAt: true, + lastUsedAt: true, + isActive: true, + }, + orderBy: { + createdAt: "desc", + }, + }); + + return apiKeys.map((key) => ({ + ...key, + createdAt: key.createdAt.toISOString(), + expiresAt: key.expiresAt ? key.expiresAt.toISOString() : null, + lastUsedAt: key.lastUsedAt ? key.lastUsedAt.toISOString() : null, + })); }); diff --git a/apps/app/src/app/(app)/[orgId]/settings/context-hub/ContextTable.tsx b/apps/app/src/app/(app)/[orgId]/settings/context-hub/ContextTable.tsx index be9792e5af..60c4c44828 100644 --- a/apps/app/src/app/(app)/[orgId]/settings/context-hub/ContextTable.tsx +++ b/apps/app/src/app/(app)/[orgId]/settings/context-hub/ContextTable.tsx @@ -11,43 +11,43 @@ import { CreateContextSheet } from "./components/CreateContextSheet"; import { columns as getColumns } from "./components/table/ContextColumns"; export const ContextTable = ({ - entries, - pageCount, + entries, + pageCount, }: { - entries: Context[]; - pageCount: number; + entries: Context[]; + pageCount: number; }) => { - const columns = useMemo(() => getColumns(), []); - const { table } = useDataTable({ - data: entries, - columns, - pageCount, - getRowId: (row) => row.id, - initialState: { - pagination: { - pageSize: 50, - pageIndex: 0, - }, - sorting: [{ id: "createdAt", desc: true }], - }, - shallow: false, - clearOnDefault: true, - }); - const [_, setOpenSheet] = useQueryState("create-context-sheet"); - return ( - <> - - - - - - - - ); + const columns = useMemo(() => getColumns(), []); + const { table } = useDataTable({ + data: entries, + columns, + pageCount, + getRowId: (row) => row.id, + initialState: { + pagination: { + pageSize: 50, + pageIndex: 0, + }, + sorting: [{ id: "createdAt", desc: true }], + }, + shallow: false, + clearOnDefault: true, + }); + const [_, setOpenSheet] = useQueryState("create-context-sheet"); + return ( + <> + + + + + + + + ); }; diff --git a/apps/app/src/app/(app)/[orgId]/settings/context-hub/components/CreateContextSheet.tsx b/apps/app/src/app/(app)/[orgId]/settings/context-hub/components/CreateContextSheet.tsx index 30d13d63b0..1f4e540ed0 100644 --- a/apps/app/src/app/(app)/[orgId]/settings/context-hub/components/CreateContextSheet.tsx +++ b/apps/app/src/app/(app)/[orgId]/settings/context-hub/components/CreateContextSheet.tsx @@ -1,72 +1,72 @@ "use client"; import { Button } from "@comp/ui/button"; import { - Drawer, - DrawerContent, - DrawerDescription, - DrawerHeader, - DrawerTitle, + Drawer, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerTitle, } from "@comp/ui/drawer"; import { useMediaQuery } from "@comp/ui/hooks"; import { ScrollArea } from "@comp/ui/scroll-area"; import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, } from "@comp/ui/sheet"; import { X } from "lucide-react"; import { useQueryState } from "nuqs"; import { ContextForm } from "./context-form"; export function CreateContextSheet() { - const isDesktop = useMediaQuery("(min-width: 768px)"); - const [open, setOpen] = useQueryState("create-context-sheet"); - const isOpen = Boolean(open); + const isDesktop = useMediaQuery("(min-width: 768px)"); + const [open, setOpen] = useQueryState("create-context-sheet"); + const isOpen = Boolean(open); - const handleOpenChange = (open: boolean) => { - setOpen(open ? "true" : null); - }; + const handleOpenChange = (open: boolean) => { + setOpen(open ? "true" : null); + }; - if (isDesktop) { - return ( - - - -
- Add Context Entry - -
- - Provide extra context to Comp AI about your organization. - -
- - setOpen(null)} /> - -
-
- ); - } - return ( - - - - Add Context Entry - - Provide extra context to Comp AI about your organization. - - - setOpen(null)} /> - - - ); + if (isDesktop) { + return ( + + + +
+ Add Context Entry + +
+ + Provide extra context to Comp AI about your organization. + +
+ + setOpen(null)} /> + +
+
+ ); + } + return ( + + + + Add Context Entry + + Provide extra context to Comp AI about your organization. + + + setOpen(null)} /> + + + ); } diff --git a/apps/app/src/app/(app)/[orgId]/settings/context-hub/components/context-form.tsx b/apps/app/src/app/(app)/[orgId]/settings/context-hub/components/context-form.tsx index 2519f65d90..d771cc9d10 100644 --- a/apps/app/src/app/(app)/[orgId]/settings/context-hub/components/context-form.tsx +++ b/apps/app/src/app/(app)/[orgId]/settings/context-hub/components/context-form.tsx @@ -12,74 +12,71 @@ import type { Context } from "@prisma/client"; import { Loader2 } from "lucide-react"; export function ContextForm({ - entry, - onSuccess, + entry, + onSuccess, }: { - entry?: Context; - onSuccess?: () => void; + entry?: Context; + onSuccess?: () => void; }) { - const [isPending, startTransition] = useTransition(); + const [isPending, startTransition] = useTransition(); - async function onSubmit(formData: FormData) { - startTransition(async () => { - try { - if (entry) { - const result = await updateContextEntryAction({ - id: entry.id, - question: formData.get("question") as string, - answer: formData.get("answer") as string, - }); - if (result?.data) { - toast.success("Context entry updated"); - onSuccess?.(); - } - } else { - const result = await createContextEntryAction({ - question: formData.get("question") as string, - answer: formData.get("answer") as string, - }); - if (result?.data) { - toast.success("Context entry created"); - onSuccess?.(); - } - } - } catch (error) { - toast.error("Something went wrong"); - } - }); - } + async function onSubmit(formData: FormData) { + startTransition(async () => { + try { + if (entry) { + const result = await updateContextEntryAction({ + id: entry.id, + question: formData.get("question") as string, + answer: formData.get("answer") as string, + }); + if (result?.data) { + toast.success("Context entry updated"); + onSuccess?.(); + } + } else { + const result = await createContextEntryAction({ + question: formData.get("question") as string, + answer: formData.get("answer") as string, + }); + if (result?.data) { + toast.success("Context entry created"); + onSuccess?.(); + } + } + } catch (error) { + toast.error("Something went wrong"); + } + }); + } - return ( -
- -
- - -
-
- -